Redesign: Permissions screen, battery optimization, location sheet (#415)

* update permissions screen design

* battery optimization screen

* remove init screen

* skip init screen on load

* skip on load only

* location sheet design wip

* denser location sheet

* location sheet layout fix
This commit is contained in:
callebtc
2025-09-14 04:22:56 +02:00
committed by GitHub
parent 861eaaeaef
commit 1a398b16ef
5 changed files with 659 additions and 482 deletions
@@ -25,6 +25,7 @@ import com.bitchat.android.onboarding.BluetoothCheckScreen
import com.bitchat.android.onboarding.BluetoothStatus
import com.bitchat.android.onboarding.BluetoothStatusManager
import com.bitchat.android.onboarding.BatteryOptimizationManager
import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager
import com.bitchat.android.onboarding.BatteryOptimizationScreen
import com.bitchat.android.onboarding.BatteryOptimizationStatus
import com.bitchat.android.onboarding.InitializationErrorScreen
@@ -163,7 +164,7 @@ class MainActivity : ComponentActivity() {
}
when (onboardingState) {
OnboardingState.CHECKING -> {
OnboardingState.PERMISSION_REQUESTING -> {
InitializingScreen(modifier)
}
@@ -227,15 +228,7 @@ class MainActivity : ComponentActivity() {
)
}
OnboardingState.PERMISSION_REQUESTING -> {
InitializingScreen(modifier)
}
OnboardingState.INITIALIZING -> {
InitializingScreen(modifier)
}
OnboardingState.COMPLETE -> {
OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> {
// Set up back navigation handling for the chat screen
val backCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
@@ -533,6 +526,13 @@ class MainActivity : ComponentActivity() {
return
}
// Check if user has previously skipped battery optimization
if (BatteryOptimizationPreferenceManager.isSkipped(this)) {
android.util.Log.d("MainActivity", "User previously skipped battery optimization, proceeding to permissions")
proceedWithPermissionCheck()
return
}
// For existing users, check battery optimization status
batteryOptimizationManager.logBatteryOptimizationStatus()
val currentBatteryOptimizationStatus = when {
@@ -0,0 +1,33 @@
package com.bitchat.android.onboarding
import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Preference manager for battery optimization skip choice
*/
object BatteryOptimizationPreferenceManager {
private const val PREFS_NAME = "bitchat_settings"
private const val KEY_BATTERY_SKIP = "battery_optimization_skipped"
private val _skipFlow = MutableStateFlow(false)
val skipFlow: StateFlow<Boolean> = _skipFlow
fun init(context: Context) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val skipped = prefs.getBoolean(KEY_BATTERY_SKIP, false)
_skipFlow.value = skipped
}
fun setSkipped(context: Context, skipped: Boolean) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putBoolean(KEY_BATTERY_SKIP, skipped).apply()
_skipFlow.value = skipped
}
fun isSkipped(context: Context): Boolean {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(KEY_BATTERY_SKIP, false)
}
}
@@ -3,6 +3,7 @@ package com.bitchat.android.onboarding
import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
@@ -12,11 +13,13 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
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.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.R
/**
@@ -32,10 +35,16 @@ fun BatteryOptimizationScreen(
onSkip: () -> Unit,
isLoading: Boolean = false
) {
val context = LocalContext.current
val colorScheme = MaterialTheme.colorScheme
// Initialize preference manager
LaunchedEffect(Unit) {
BatteryOptimizationPreferenceManager.init(context)
}
Box(
modifier = modifier.padding(32.dp),
modifier = modifier.padding(24.dp),
contentAlignment = Alignment.Center
) {
when (status) {
@@ -73,6 +82,8 @@ private fun BatteryOptimizationEnabledContent(
colorScheme: ColorScheme,
isLoading: Boolean
) {
val context = LocalContext.current
Column(
modifier = Modifier.fillMaxSize()
) {
@@ -82,78 +93,112 @@ private fun BatteryOptimizationEnabledContent(
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "bitchat",
style = MaterialTheme.typography.headlineLarge.copy(
// Header Section - matching AboutSheet style
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "bitchat",
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 32.sp
),
color = colorScheme.onBackground
)
Text(
text = "battery optimization detected",
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = colorScheme.primary
color = colorScheme.onBackground.copy(alpha = 0.7f)
)
)
}
Spacer(modifier = Modifier.height(16.dp))
Icon(
imageVector = Icons.Outlined.BatteryAlert,
contentDescription = "Battery Optimization",
modifier = Modifier.size(64.dp),
tint = colorScheme.error
)
Text(
text = stringResource(R.string.battery_optimization_detected),
style = MaterialTheme.typography.headlineSmall.copy(
fontWeight = FontWeight.SemiBold,
color = colorScheme.onSurface
),
textAlign = TextAlign.Center
)
Text(
text = stringResource(R.string.battery_optimization_explanation),
style = MaterialTheme.typography.bodyLarge.copy(
color = colorScheme.onSurfaceVariant
),
textAlign = TextAlign.Center
)
Card(
// Battery optimization info section
Surface(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.battery_optimization_why_disable),
style = MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.SemiBold,
color = colorScheme.onSurface
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Filled.Power,
contentDescription = "Battery Optimization",
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.size(20.dp)
)
)
Text(
text = stringResource(R.string.battery_optimization_benefits),
style = MaterialTheme.typography.bodyMedium.copy(
color = colorScheme.onSurfaceVariant
)
)
Column {
Text(
text = "Battery Optimization Enabled",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "bitchat needs to run in the background to maintain mesh connections. battery optimization can interrupt these connections.",
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onBackground.copy(alpha = 0.8f)
)
}
}
}
}
Text(
text = stringResource(R.string.battery_optimization_note),
style = MaterialTheme.typography.bodySmall.copy(
color = colorScheme.onSurfaceVariant
),
textAlign = TextAlign.Center
)
// Benefits section
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = "Benefits",
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.size(20.dp)
)
Column {
Text(
text = "Benefits of Disabling",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "• reliable message delivery\n• maintains mesh connectivity\n• prevents connection drops",
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onBackground.copy(alpha = 0.8f)
)
}
}
}
}
}
// Fixed buttons at the bottom
@@ -164,7 +209,10 @@ private fun BatteryOptimizationEnabledContent(
Button(
onClick = onDisableBatteryOptimization,
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading
enabled = !isLoading,
colors = ButtonDefaults.buttonColors(
containerColor = colorScheme.primary
)
) {
if (isLoading) {
CircularProgressIndicator(
@@ -174,7 +222,13 @@ private fun BatteryOptimizationEnabledContent(
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.battery_optimization_disable_button))
Text(
text = "Disable Battery Optimization",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
)
}
Row(
@@ -186,15 +240,28 @@ private fun BatteryOptimizationEnabledContent(
modifier = Modifier.weight(1f),
enabled = !isLoading
) {
Text(stringResource(R.string.battery_optimization_check_again))
Text(
text = "Check Again",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
)
)
}
TextButton(
onClick = onSkip,
onClick = {
BatteryOptimizationPreferenceManager.setSkipped(context, true)
onSkip()
},
modifier = Modifier.weight(1f),
enabled = !isLoading
) {
Text(stringResource(R.string.battery_optimization_skip))
Text(
text = "Skip for Now",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
)
)
}
}
}
@@ -206,17 +273,31 @@ private fun BatteryOptimizationCheckingContent(
colorScheme: ColorScheme
) {
Column(
verticalArrangement = Arrangement.spacedBy(32.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "bitchat",
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = colorScheme.primary
// Header Section - matching AboutSheet style
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "bitchat",
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 32.sp
),
color = colorScheme.onBackground
)
)
Text(
text = "battery optimization disabled",
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.7f)
)
}
val infiniteTransition = rememberInfiniteTransition(label = "rotation")
val rotation by infiniteTransition.animateFloat(
@@ -239,18 +320,10 @@ private fun BatteryOptimizationCheckingContent(
)
Text(
text = stringResource(R.string.battery_optimization_disabled),
style = MaterialTheme.typography.headlineSmall.copy(
fontWeight = FontWeight.SemiBold,
color = colorScheme.onSurface
),
textAlign = TextAlign.Center
)
Text(
text = stringResource(R.string.battery_optimization_success_message),
style = MaterialTheme.typography.bodyLarge.copy(
color = colorScheme.onSurfaceVariant
text = "bitchat can run reliably in the background",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.8f)
),
textAlign = TextAlign.Center
)
@@ -266,14 +339,28 @@ private fun BatteryOptimizationNotSupportedContent(
verticalArrangement = Arrangement.spacedBy(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "bitchat",
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = colorScheme.primary
// Header Section - matching AboutSheet style
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "bitchat",
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 32.sp
),
color = colorScheme.onBackground
)
)
Text(
text = "battery optimization not required",
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.7f)
)
}
Icon(
imageVector = Icons.Filled.CheckCircle,
@@ -283,27 +370,28 @@ private fun BatteryOptimizationNotSupportedContent(
)
Text(
text = stringResource(R.string.battery_optimization_not_required),
style = MaterialTheme.typography.headlineSmall.copy(
fontWeight = FontWeight.SemiBold,
color = colorScheme.onSurface
),
textAlign = TextAlign.Center
)
Text(
text = stringResource(R.string.battery_optimization_not_supported_message),
style = MaterialTheme.typography.bodyLarge.copy(
color = colorScheme.onSurfaceVariant
text = "your device doesn't require battery optimization settings. bitchat will run normally.",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.8f)
),
textAlign = TextAlign.Center
)
Button(
onClick = onRetry,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = colorScheme.primary
)
) {
Text(stringResource(R.string.battery_optimization_continue))
Text(
text = "Continue",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
)
}
}
}
@@ -2,12 +2,22 @@ package com.bitchat.android.onboarding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -40,86 +50,87 @@ fun PermissionExplanationScreen(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(24.dp))
// Header
// Header Section - matching AboutSheet style
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Welcome to bitchat",
style = MaterialTheme.typography.headlineMedium.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = colorScheme.primary
),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Bottom,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "bitchat",
style = MaterialTheme.typography.headlineLarge.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 32.sp
),
color = colorScheme.onBackground
)
}
Text(
text = "Decentralized mesh messaging over Bluetooth",
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.7f)
),
textAlign = TextAlign.Center
text = "decentralized mesh messaging with end-to-end encryption",
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.7f)
)
}
Spacer(modifier = Modifier.height(16.dp))
// Privacy assurance section
Card(
// Privacy assurance section - matching AboutSheet card style
Surface(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
color = colorScheme.surfaceVariant.copy(alpha = 0.25f),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "🔒",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.size(20.dp)
Icon(
imageVector = Icons.Filled.Security,
contentDescription = "Privacy Protected",
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.size(20.dp)
)
Text(
text = "Your Privacy is Protected",
style = MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.Bold,
color = colorScheme.onSurface
Column {
Text(
text = "Your Privacy is Protected",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onBackground
)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "• no tracking or data collection\n" +
"• Bluetooth mesh chats are fully offline\n" +
"• Geohash chats use the internet",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.8f)
)
}
}
Text(
text = "• bitchat doesn't track you or collect personal data\n" +
"• Bluetooth mesh chats are fully offline and require no internet\n" +
"• Geohash chats use the internet but your location is generalized\n" +
"• Your messages stay on your device and peer devices only",
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f)
)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Section header
Text(
text = "To work properly, bitchat needs these permissions:",
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
text = "permissions",
style = MaterialTheme.typography.labelLarge,
color = colorScheme.onBackground.copy(alpha = 0.7f),
modifier = Modifier.padding(top = 8.dp, bottom = 8.dp)
)
// Permission categories
@@ -168,55 +179,46 @@ private fun PermissionCategoryCard(
category: PermissionCategory,
colorScheme: ColorScheme
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = getPermissionEmoji(category.type),
style = MaterialTheme.typography.titleLarge,
color = getPermissionIconColor(category.type),
modifier = Modifier.size(24.dp)
)
Text(
text = category.type.nameValue,
style = MaterialTheme.typography.titleSmall.copy(
fontWeight = FontWeight.Bold,
color = colorScheme.onSurface
)
)
}
Icon(
imageVector = getPermissionIcon(category.type),
contentDescription = category.type.nameValue,
tint = colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
.size(20.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = category.type.nameValue,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = category.description,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f),
lineHeight = 18.sp
)
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onBackground.copy(alpha = 0.8f)
)
if (category.type == PermissionType.PRECISE_LOCATION) {
// Extra emphasis for location permission
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "⚠️",
style = MaterialTheme.typography.bodyMedium,
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = "Warning",
tint = Color(0xFFFF9800),
modifier = Modifier.size(16.dp)
)
Text(
@@ -233,22 +235,12 @@ private fun PermissionCategoryCard(
}
}
private fun getPermissionEmoji(permissionType: PermissionType): String {
private fun getPermissionIcon(permissionType: PermissionType): ImageVector {
return when (permissionType) {
PermissionType.NEARBY_DEVICES -> "📱"
PermissionType.PRECISE_LOCATION -> "📍"
PermissionType.NOTIFICATIONS -> "🔔"
PermissionType.BATTERY_OPTIMIZATION -> "🔋"
PermissionType.OTHER -> "🔧"
}
}
private fun getPermissionIconColor(permissionType: PermissionType): Color {
return when (permissionType) {
PermissionType.NEARBY_DEVICES -> Color(0xFF2196F3) // Blue
PermissionType.PRECISE_LOCATION -> Color(0xFFFF9800) // Orange
PermissionType.NOTIFICATIONS -> Color(0xFF4CAF50) // Green
PermissionType.BATTERY_OPTIMIZATION -> Color(0xFFF44336) // Red
PermissionType.OTHER -> Color(0xFF9C27B0) // Purple
PermissionType.NEARBY_DEVICES -> Icons.Filled.Bluetooth
PermissionType.PRECISE_LOCATION -> Icons.Filled.LocationOn
PermissionType.NOTIFICATIONS -> Icons.Filled.Notifications
PermissionType.BATTERY_OPTIMIZATION -> Icons.Filled.Power
PermissionType.OTHER -> Icons.Filled.Settings
}
}
@@ -3,6 +3,8 @@ 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.items
@@ -73,12 +75,21 @@ fun LocationChannelsSheet(
// Bottom sheet state
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = isInputFocused
skipPartiallyExpanded = true
)
val coroutineScope = rememberCoroutineScope()
// Scroll state for LazyColumn
// Scroll state for LazyColumn with animated top bar
val listState = rememberLazyListState()
val isScrolled by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
}
}
val topBarAlpha by animateFloatAsState(
targetValue = if (isScrolled) 0.95f else 0f,
label = "topBarAlpha"
)
val mapPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
@@ -100,114 +111,125 @@ fun LocationChannelsSheet(
if (isPresented) {
ModalBottomSheet(
modifier = modifier.statusBarsPadding(),
onDismissRequest = onDismiss,
sheetState = sheetState,
modifier = modifier
containerColor = MaterialTheme.colorScheme.background,
dragHandle = null
) {
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)
Box(modifier = Modifier.fillMaxWidth()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 48.dp, bottom = 16.dp)
) {
// Header Section
item(key = "header") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "#location channels",
style = MaterialTheme.typography.headlineSmall,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
)
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.onBackground.copy(alpha = 0.7f)
)
}
),
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)
)
// Permission controls if services 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()
// Permission controls if services enabled
if (locationServicesEnabled) {
item(key = "permissions") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "grant location permission",
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
}
}
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)
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
)
}
}
LocationChannelManager.PermissionState.DENIED,
LocationChannelManager.PermissionState.RESTRICTED -> {
Column(verticalArrangement = Arrangement.spacedBy(4.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)
}
) {
Text(
text = "open settings",
fontSize = 11.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(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(modifier = Modifier.size(12.dp))
Text(
text = "checking permissions...",
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
context.startActivity(intent)
}
) {
Text(
text = "open settings",
fontSize = 11.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))
Text(
text = "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 {
item(key = "mesh") {
ChannelRow(
title = meshTitleWithCount(viewModel),
subtitle = "#bluetooth • ${bluetoothRangeString()}",
@@ -259,7 +281,7 @@ fun LocationChannelsSheet(
} else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
item {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
@@ -274,13 +296,16 @@ fun LocationChannelsSheet(
// Bookmarked geohashes
if (bookmarks.isNotEmpty()) {
item {
item(key = "bookmarked_header") {
Text(
text = "bookmarked",
fontSize = 12.sp,
style = MaterialTheme.typography.labelLarge,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 8.dp, bottom = 4.dp)
)
}
items(bookmarks) { gh ->
@@ -325,182 +350,219 @@ fun LocationChannelsSheet(
}
// Custom geohash teleport (iOS-style inline form)
item {
item(key = "custom_geohash") {
Surface(
color = Color.Transparent,
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
color = Color.Transparent
.padding(horizontal = 24.dp, vertical = 2.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
horizontalArrangement = Arrangement.spacedBy(1.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "#",
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.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 = androidx.compose.ui.text.TextStyle(
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
)
}
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()
}
)
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
) {
singleLine = true,
decorationBox = { innerTextField ->
if (customGeohash.isEmpty()) {
Text(
text = "teleport",
text = "geohash",
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
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
}
innerTextField()
}
)
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)
)
}
customError?.let { error ->
Text(
text = error,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = Color.Red
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(4.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
)
}
}
}
}
}
// 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()
) {
// Error message for custom geohash
if (customError != null) {
item(key = "geohash_error") {
Text(
text = if (locationServicesEnabled) {
"disable location services"
} else {
"enable location services"
},
text = customError!!,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
fontFamily = FontFamily.Monospace,
color = Color.Red,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
)
}
}
// Location services toggle button
item(key = "location_toggle") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(top = 8.dp)
) {
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()
) {
Text(
text = if (locationServicesEnabled) {
"disable location services"
} else {
"enable location services"
},
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
}
}
}
}
// TopBar (animated)
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.height(56.dp)
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
) {
TextButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(horizontal = 16.dp)
) {
Text(
text = "Close",
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onBackground
)
}
}
}
}
@@ -557,18 +619,20 @@ private fun ChannelRow(
Color.Transparent
},
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(horizontal = 16.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
// Split title to handle count part with smaller font (iOS style)
val (baseTitle, countSuffix) = splitTitleAndCount(title)