From 0a33bd734d45d7f0ea64078ee28bd91c38947718 Mon Sep 17 00:00:00 2001 From: Mohamad Hamade Date: Mon, 14 Jul 2025 22:16:19 +0300 Subject: [PATCH 1/6] Bugfix: Handled single or triple clicked at once instead of executing them as separate events This commit introduces a new `singleOrTripleClickable` Modifier extension function. This function allows distinguishing between single and triple clicks on a Composable element. The `ChatHeader` was updated to utilize this new Modifier for handling clicks on the "bitchat*" title, where a single click shows app info and a triple click triggers a separate action. --- .../android/core/ui/utils/ModifierExt.kt | 57 +++++++++++++++++++ .../java/com/bitchat/android/ui/ChatHeader.kt | 18 +++--- 2 files changed, 65 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/core/ui/utils/ModifierExt.kt diff --git a/app/src/main/java/com/bitchat/android/core/ui/utils/ModifierExt.kt b/app/src/main/java/com/bitchat/android/core/ui/utils/ModifierExt.kt new file mode 100644 index 00000000..19dabb57 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/utils/ModifierExt.kt @@ -0,0 +1,57 @@ +package com.bitchat.android.core.ui.utils + +import androidx.compose.foundation.clickable +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +fun Modifier.singleOrTripleClickable( + onSingleClick: () -> Unit, + onTripleClick: () -> Unit, + clickTimeThreshold: Long = 300L +): Modifier = composed { + var tapCount by remember { mutableIntStateOf(0) } + var lastTapTime by remember { mutableLongStateOf(0L) } + var singleClickJob by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + + this.clickable { + val currentTime = System.currentTimeMillis() + + if (currentTime - lastTapTime < clickTimeThreshold) { + tapCount++ + } else { + tapCount = 1 + } + + lastTapTime = currentTime + + // Cancel any pending single click action + singleClickJob?.cancel() + singleClickJob = null + + when (tapCount) { + 1 -> { + // Wait to see if more taps come + singleClickJob = coroutineScope.launch { + delay(clickTimeThreshold) + if (tapCount == 1) { + onSingleClick() + } + } + } + 3 -> { + // Triple click detected - execute immediately + onTripleClick() + tapCount = 0 + } + } + + // Reset after threshold if no triple click + if (tapCount > 3) { + tapCount = 0 + } + } +} \ No newline at end of file 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 65e2ee8e..1b9fd098 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bitchat.android.core.ui.utils.singleOrTripleClickable /** * Header components for ChatScreen @@ -181,15 +182,8 @@ fun ChatHeaderContent( MainHeader( nickname = nickname, onNicknameChange = viewModel::setNickname, - onTitleClick = { - tripleClickCount++ - if (tripleClickCount >= 3) { - tripleClickCount = 0 - onTripleClick() - } else { - onShowAppInfo() - } - }, + onTitleClick = onShowAppInfo, + onTripleTitleClick = onTripleClick, onSidebarClick = onSidebarClick, viewModel = viewModel ) @@ -345,6 +339,7 @@ private fun MainHeader( nickname: String, onNicknameChange: (String) -> Unit, onTitleClick: () -> Unit, + onTripleTitleClick: () -> Unit, onSidebarClick: () -> Unit, viewModel: ChatViewModel ) { @@ -365,7 +360,10 @@ private fun MainHeader( text = "bitchat*", style = MaterialTheme.typography.headlineSmall, color = colorScheme.primary, - modifier = Modifier.clickable { onTitleClick() } + modifier = Modifier.singleOrTripleClickable( + onSingleClick = onTitleClick, + onTripleClick = onTripleTitleClick + ) ) Spacer(modifier = Modifier.width(8.dp)) From 245181d73671c748ffdf3a5325aa62d2ed614a25 Mon Sep 17 00:00:00 2001 From: Mohamad Hamade Date: Tue, 15 Jul 2025 02:24:41 +0300 Subject: [PATCH 2/6] Refactor: Introduce MainViewModel for onboarding state management This commit introduces a `MainViewModel` to manage the UI state for the onboarding flow. This change centralizes the onboarding state (including Bluetooth status, location status, error messages, and loading indicators) within the ViewModel, allowing it to survive configuration changes and simplifying state management within `MainActivity`. Key changes: - Created `MainViewModel.kt` to hold and manage onboarding-related UI state. - Moved onboarding state variables (e.g., `onboardingState`, `bluetoothStatus`, `locationStatus`) from `MainActivity` to `MainViewModel`. - Updated `MainActivity` to observe and update onboarding state through the `MainViewModel`. - Created `OnboardingState.kt` to define the possible states of the onboarding process. - Ensured that the onboarding process is not restarted on configuration changes by checking `mainViewModel.onboardingState` before initiating. --- .../java/com/bitchat/android/MainActivity.kt | 190 ++++++++---------- .../java/com/bitchat/android/MainViewModel.kt | 52 +++++ .../com/bitchat/android/OnboardingState.kt | 12 ++ 3 files changed, 153 insertions(+), 101 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/MainViewModel.kt create mode 100644 app/src/main/java/com/bitchat/android/OnboardingState.kt diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 1ed97a92..232fca28 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -1,27 +1,30 @@ package com.bitchat.android -import android.Manifest import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.ViewModelProvider import androidx.activity.OnBackPressedCallback -import androidx.activity.addCallback +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import com.bitchat.android.mesh.BluetoothMeshService -import com.bitchat.android.onboarding.* +import com.bitchat.android.onboarding.BluetoothCheckScreen +import com.bitchat.android.onboarding.BluetoothStatus +import com.bitchat.android.onboarding.BluetoothStatusManager +import com.bitchat.android.onboarding.InitializationErrorScreen +import com.bitchat.android.onboarding.InitializingScreen +import com.bitchat.android.onboarding.LocationCheckScreen +import com.bitchat.android.onboarding.LocationStatus +import com.bitchat.android.onboarding.LocationStatusManager +import com.bitchat.android.onboarding.OnboardingCoordinator +import com.bitchat.android.onboarding.PermissionExplanationScreen +import com.bitchat.android.onboarding.PermissionManager import com.bitchat.android.ui.ChatScreen import com.bitchat.android.ui.ChatViewModel import com.bitchat.android.ui.theme.BitchatTheme @@ -37,6 +40,7 @@ class MainActivity : ComponentActivity() { // Core mesh service - managed at app level private lateinit var meshService: BluetoothMeshService + private val mainViewModel: MainViewModel by viewModels() private val chatViewModel: ChatViewModel by viewModels { object : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @@ -46,25 +50,6 @@ class MainActivity : ComponentActivity() { } } - // UI state for onboarding flow - private var onboardingState by mutableStateOf(OnboardingState.CHECKING) - private var bluetoothStatus by mutableStateOf(BluetoothStatus.ENABLED) - private var locationStatus by mutableStateOf(LocationStatus.ENABLED) - private var errorMessage by mutableStateOf("") - private var isBluetoothLoading by mutableStateOf(false) - private var isLocationLoading by mutableStateOf(false) - - enum class OnboardingState { - CHECKING, - BLUETOOTH_CHECK, - LOCATION_CHECK, - PERMISSION_EXPLANATION, - PERMISSION_REQUESTING, - INITIALIZING, - COMPLETE, - ERROR - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -103,42 +88,45 @@ class MainActivity : ComponentActivity() { } } - // Start the onboarding process - checkOnboardingStatus() + // Only start onboarding process if we're in the initial CHECKING state + // This prevents restarting onboarding on configuration changes + if (mainViewModel.onboardingState == OnboardingState.CHECKING) { + checkOnboardingStatus() + } } @Composable private fun OnboardingFlowScreen() { - when (onboardingState) { + when (mainViewModel.onboardingState) { OnboardingState.CHECKING -> { InitializingScreen() } OnboardingState.BLUETOOTH_CHECK -> { BluetoothCheckScreen( - status = bluetoothStatus, + status = mainViewModel.bluetoothStatus, onEnableBluetooth = { - isBluetoothLoading = true + mainViewModel.updateBluetoothLoading(true) bluetoothStatusManager.requestEnableBluetooth() }, onRetry = { checkBluetoothAndProceed() }, - isLoading = isBluetoothLoading + isLoading = mainViewModel.isBluetoothLoading ) } OnboardingState.LOCATION_CHECK -> { LocationCheckScreen( - status = locationStatus, + status = mainViewModel.locationStatus, onEnableLocation = { - isLocationLoading = true + mainViewModel.updateLocationLoading(true) locationStatusManager.requestEnableLocation() }, onRetry = { checkLocationAndProceed() }, - isLoading = isLocationLoading + isLoading = mainViewModel.isLocationLoading ) } @@ -146,7 +134,7 @@ class MainActivity : ComponentActivity() { PermissionExplanationScreen( permissionCategories = permissionManager.getCategorizedPermissions(), onContinue = { - onboardingState = OnboardingState.PERMISSION_REQUESTING + mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_REQUESTING) onboardingCoordinator.requestPermissions() } ) @@ -184,9 +172,9 @@ class MainActivity : ComponentActivity() { OnboardingState.ERROR -> { InitializationErrorScreen( - errorMessage = errorMessage, + errorMessage = mainViewModel.errorMessage, onRetry = { - onboardingState = OnboardingState.CHECKING + mainViewModel.updateOnboardingState(OnboardingState.CHECKING) checkOnboardingStatus() }, onOpenSettings = { @@ -225,9 +213,9 @@ class MainActivity : ComponentActivity() { // For existing users, check Bluetooth status first bluetoothStatusManager.logBluetoothStatus() - bluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() + mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus()) - when (bluetoothStatus) { + when (mainViewModel.bluetoothStatus) { BluetoothStatus.ENABLED -> { // Bluetooth is enabled, check location services next checkLocationAndProceed() @@ -235,14 +223,14 @@ class MainActivity : ComponentActivity() { BluetoothStatus.DISABLED -> { // Show Bluetooth enable screen (should have permissions as existing user) android.util.Log.d("MainActivity", "Bluetooth disabled, showing enable screen") - onboardingState = OnboardingState.BLUETOOTH_CHECK - isBluetoothLoading = false + mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) + mainViewModel.updateBluetoothLoading(false) } BluetoothStatus.NOT_SUPPORTED -> { // Device doesn't support Bluetooth android.util.Log.e("MainActivity", "Bluetooth not supported") - onboardingState = OnboardingState.BLUETOOTH_CHECK - isBluetoothLoading = false + mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) + mainViewModel.updateBluetoothLoading(false) } } } @@ -258,14 +246,14 @@ class MainActivity : ComponentActivity() { if (permissionManager.isFirstTimeLaunch()) { android.util.Log.d("MainActivity", "First time launch, showing permission explanation") - onboardingState = OnboardingState.PERMISSION_EXPLANATION + mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) } else if (permissionManager.areAllPermissionsGranted()) { android.util.Log.d("MainActivity", "Existing user with permissions, initializing app") - onboardingState = OnboardingState.INITIALIZING + mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) initializeApp() } else { android.util.Log.d("MainActivity", "Existing user missing permissions, showing explanation") - onboardingState = OnboardingState.PERMISSION_EXPLANATION + mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) } } } @@ -275,8 +263,8 @@ class MainActivity : ComponentActivity() { */ private fun handleBluetoothEnabled() { android.util.Log.d("MainActivity", "Bluetooth enabled by user") - isBluetoothLoading = false - bluetoothStatus = BluetoothStatus.ENABLED + mainViewModel.updateBluetoothLoading(false) + mainViewModel.updateBluetoothStatus(BluetoothStatus.ENABLED) checkLocationAndProceed() } @@ -296,9 +284,9 @@ class MainActivity : ComponentActivity() { // For existing users, check location status locationStatusManager.logLocationStatus() - locationStatus = locationStatusManager.checkLocationStatus() + mainViewModel.updateLocationStatus(locationStatusManager.checkLocationStatus()) - when (locationStatus) { + when (mainViewModel.locationStatus) { LocationStatus.ENABLED -> { // Location services enabled, proceed with permission/onboarding check proceedWithPermissionCheck() @@ -306,14 +294,14 @@ class MainActivity : ComponentActivity() { LocationStatus.DISABLED -> { // Show location enable screen (should have permissions as existing user) android.util.Log.d("MainActivity", "Location services disabled, showing enable screen") - onboardingState = OnboardingState.LOCATION_CHECK - isLocationLoading = false + mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) + mainViewModel.updateLocationLoading(false) } LocationStatus.NOT_AVAILABLE -> { // Device doesn't support location services (very unusual) android.util.Log.e("MainActivity", "Location services not available") - onboardingState = OnboardingState.LOCATION_CHECK - isLocationLoading = false + mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) + mainViewModel.updateLocationLoading(false) } } } @@ -323,8 +311,8 @@ class MainActivity : ComponentActivity() { */ private fun handleLocationEnabled() { android.util.Log.d("MainActivity", "Location services enabled by user") - isLocationLoading = false - locationStatus = LocationStatus.ENABLED + mainViewModel.updateLocationLoading(false) + mainViewModel.updateLocationStatus(LocationStatus.ENABLED) proceedWithPermissionCheck() } @@ -333,18 +321,18 @@ class MainActivity : ComponentActivity() { */ private fun handleLocationDisabled(message: String) { android.util.Log.w("MainActivity", "Location services disabled or failed: $message") - isLocationLoading = false - locationStatus = locationStatusManager.checkLocationStatus() + mainViewModel.updateLocationLoading(false) + mainViewModel.updateLocationStatus(locationStatusManager.checkLocationStatus()) when { - locationStatus == LocationStatus.NOT_AVAILABLE -> { + mainViewModel.locationStatus == LocationStatus.NOT_AVAILABLE -> { // Show permanent error for devices without location services - errorMessage = message - onboardingState = OnboardingState.ERROR + mainViewModel.updateErrorMessage(message) + mainViewModel.updateOnboardingState(OnboardingState.ERROR) } else -> { // Stay on location check screen for retry - onboardingState = OnboardingState.LOCATION_CHECK + mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) } } } @@ -354,14 +342,14 @@ class MainActivity : ComponentActivity() { */ private fun handleBluetoothDisabled(message: String) { android.util.Log.w("MainActivity", "Bluetooth disabled or failed: $message") - isBluetoothLoading = false - bluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() + mainViewModel.updateBluetoothLoading(false) + mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus()) when { - bluetoothStatus == BluetoothStatus.NOT_SUPPORTED -> { + mainViewModel.bluetoothStatus == BluetoothStatus.NOT_SUPPORTED -> { // Show permanent error for unsupported devices - errorMessage = message - onboardingState = OnboardingState.ERROR + mainViewModel.updateErrorMessage(message) + mainViewModel.updateOnboardingState(OnboardingState.ERROR) } message.contains("Permission") && permissionManager.isFirstTimeLaunch() -> { // During first-time onboarding, if Bluetooth enable fails due to permissions, @@ -372,11 +360,11 @@ class MainActivity : ComponentActivity() { message.contains("Permission") -> { // For existing users, redirect to permission explanation to grant missing permissions android.util.Log.d("MainActivity", "Bluetooth enable requires permissions, showing permission explanation") - onboardingState = OnboardingState.PERMISSION_EXPLANATION + mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) } else -> { // Stay on Bluetooth check screen for retry - onboardingState = OnboardingState.BLUETOOTH_CHECK + mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) } } } @@ -392,21 +380,21 @@ class MainActivity : ComponentActivity() { currentBluetoothStatus != BluetoothStatus.ENABLED -> { // Bluetooth still disabled, but now we have permissions to enable it android.util.Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.") - bluetoothStatus = currentBluetoothStatus - onboardingState = OnboardingState.BLUETOOTH_CHECK - isBluetoothLoading = false + mainViewModel.updateBluetoothStatus(currentBluetoothStatus) + mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) + mainViewModel.updateBluetoothLoading(false) } currentLocationStatus != LocationStatus.ENABLED -> { // Location services still disabled, but now we have permissions to enable it android.util.Log.d("MainActivity", "Permissions granted, but Location services still disabled. Showing Location enable screen.") - locationStatus = currentLocationStatus - onboardingState = OnboardingState.LOCATION_CHECK - isLocationLoading = false + mainViewModel.updateLocationStatus(currentLocationStatus) + mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) + mainViewModel.updateLocationLoading(false) } else -> { // Both are enabled, proceed to app initialization android.util.Log.d("MainActivity", "Both Bluetooth and Location services are enabled, proceeding to initialization") - onboardingState = OnboardingState.INITIALIZING + mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) initializeApp() } } @@ -414,8 +402,8 @@ class MainActivity : ComponentActivity() { private fun handleOnboardingFailed(message: String) { android.util.Log.e("MainActivity", "Onboarding failed: $message") - errorMessage = message - onboardingState = OnboardingState.ERROR + mainViewModel.updateErrorMessage(message) + mainViewModel.updateOnboardingState(OnboardingState.ERROR) } private fun initializeApp() { @@ -450,7 +438,7 @@ class MainActivity : ComponentActivity() { delay(500) android.util.Log.d("MainActivity", "App initialization complete") - onboardingState = OnboardingState.COMPLETE + mainViewModel.updateOnboardingState(OnboardingState.COMPLETE) } catch (e: Exception) { android.util.Log.e("MainActivity", "Failed to initialize app", e) @@ -462,7 +450,7 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // Handle notification intents when app is already running - if (onboardingState == OnboardingState.COMPLETE) { + if (mainViewModel.onboardingState == OnboardingState.COMPLETE) { handleNotificationIntent(intent) } } @@ -470,7 +458,7 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() // Check Bluetooth and Location status on resume and handle accordingly - if (onboardingState == OnboardingState.COMPLETE) { + if (mainViewModel.onboardingState == OnboardingState.COMPLETE) { // Set app foreground state meshService.connectionManager.setAppBackgroundState(false) chatViewModel.setAppBackgroundState(false) @@ -479,9 +467,9 @@ class MainActivity : ComponentActivity() { val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() if (currentBluetoothStatus != BluetoothStatus.ENABLED) { android.util.Log.w("MainActivity", "Bluetooth disabled while app was backgrounded") - bluetoothStatus = currentBluetoothStatus - onboardingState = OnboardingState.BLUETOOTH_CHECK - isBluetoothLoading = false + mainViewModel.updateBluetoothStatus(currentBluetoothStatus) + mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) + mainViewModel.updateBluetoothLoading(false) return } @@ -489,9 +477,9 @@ class MainActivity : ComponentActivity() { val currentLocationStatus = locationStatusManager.checkLocationStatus() if (currentLocationStatus != LocationStatus.ENABLED) { android.util.Log.w("MainActivity", "Location services disabled while app was backgrounded") - locationStatus = currentLocationStatus - onboardingState = OnboardingState.LOCATION_CHECK - isLocationLoading = false + mainViewModel.updateLocationStatus(currentLocationStatus) + mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) + mainViewModel.updateLocationLoading(false) } } } @@ -499,7 +487,7 @@ class MainActivity : ComponentActivity() { override fun onPause() { super.onPause() // Only set background state if app is fully initialized - if (onboardingState == OnboardingState.COMPLETE) { + if (mainViewModel.onboardingState == OnboardingState.COMPLETE) { // Set app background state meshService.connectionManager.setAppBackgroundState(true) chatViewModel.setAppBackgroundState(true) @@ -535,7 +523,7 @@ class MainActivity : ComponentActivity() { * Restart mesh services (for debugging/troubleshooting) */ fun restartMeshServices() { - if (onboardingState == OnboardingState.COMPLETE) { + if (mainViewModel.onboardingState == OnboardingState.COMPLETE) { lifecycleScope.launch { try { android.util.Log.d("MainActivity", "Restarting mesh services") @@ -562,7 +550,7 @@ class MainActivity : ComponentActivity() { } // Stop mesh services if app was fully initialized - if (onboardingState == OnboardingState.COMPLETE) { + if (mainViewModel.onboardingState == OnboardingState.COMPLETE) { try { meshService.stopServices() android.util.Log.d("MainActivity", "Mesh services stopped successfully") diff --git a/app/src/main/java/com/bitchat/android/MainViewModel.kt b/app/src/main/java/com/bitchat/android/MainViewModel.kt new file mode 100644 index 00000000..61e13d63 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/MainViewModel.kt @@ -0,0 +1,52 @@ +package com.bitchat.android + +import androidx.compose.runtime.* +import androidx.lifecycle.ViewModel +import com.bitchat.android.onboarding.BluetoothStatus +import com.bitchat.android.onboarding.LocationStatus + +class MainViewModel : ViewModel() { + + private var _onboardingState by mutableStateOf(OnboardingState.CHECKING) + val onboardingState: OnboardingState get() = _onboardingState + + private var _bluetoothStatus by mutableStateOf(BluetoothStatus.ENABLED) + val bluetoothStatus: BluetoothStatus get() = _bluetoothStatus + + private var _locationStatus by mutableStateOf(LocationStatus.ENABLED) + val locationStatus: LocationStatus get() = _locationStatus + + private var _errorMessage by mutableStateOf("") + val errorMessage: String get() = _errorMessage + + private var _isBluetoothLoading by mutableStateOf(false) + val isBluetoothLoading: Boolean get() = _isBluetoothLoading + + private var _isLocationLoading by mutableStateOf(false) + val isLocationLoading: Boolean get() = _isLocationLoading + + // Public update functions for MainActivity + fun updateOnboardingState(state: OnboardingState) { + _onboardingState = state + } + + fun updateBluetoothStatus(status: BluetoothStatus) { + _bluetoothStatus = status + } + + fun updateLocationStatus(status: LocationStatus) { + _locationStatus = status + } + + fun updateErrorMessage(message: String) { + _errorMessage = message + } + + fun updateBluetoothLoading(loading: Boolean) { + _isBluetoothLoading = loading + } + + fun updateLocationLoading(loading: Boolean) { + _isLocationLoading = loading + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/OnboardingState.kt b/app/src/main/java/com/bitchat/android/OnboardingState.kt new file mode 100644 index 00000000..a5a1e83b --- /dev/null +++ b/app/src/main/java/com/bitchat/android/OnboardingState.kt @@ -0,0 +1,12 @@ +package com.bitchat.android + +enum class OnboardingState { + CHECKING, + BLUETOOTH_CHECK, + LOCATION_CHECK, + PERMISSION_EXPLANATION, + PERMISSION_REQUESTING, + INITIALIZING, + COMPLETE, + ERROR +} \ No newline at end of file From 17472a18541e4ffcdc844458132cbb2a429c26c1 Mon Sep 17 00:00:00 2001 From: Mohamad Hamade Date: Tue, 15 Jul 2025 13:18:29 +0300 Subject: [PATCH 3/6] Refactor: Remove unused tripleClickCount state variable The `tripleClickCount` mutable state variable in the `ChatHeader` composable was not being used and has been removed to simplify the code --- app/src/main/java/com/bitchat/android/ui/ChatHeader.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 1b9fd098..ee9c699d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -149,8 +149,7 @@ fun ChatHeaderContent( onShowAppInfo: () -> Unit ) { val colorScheme = MaterialTheme.colorScheme - var tripleClickCount by remember { mutableStateOf(0) } - + when { selectedPrivatePeer != null -> { // Private chat header - ensure state synchronization From b14eb9006b09a8b65225997cf676575e5115d198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Demat=C3=A9=20Jr?= Date: Tue, 15 Jul 2025 18:47:32 -0300 Subject: [PATCH 4/6] fix: update sidebar text to use string resources --- .../com/bitchat/android/ui/SidebarComponents.kt | 14 +++++++------- app/src/main/res/values/strings.xml | 1 + 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt index 27e6927f..f34ed992 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -1,8 +1,7 @@ package com.bitchat.android.ui +import com.bitchat.android.R import android.util.Log -import androidx.compose.animation.* -import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -16,11 +15,12 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment 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 import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex + /** * Sidebar components for ChatScreen @@ -140,7 +140,7 @@ private fun SidebarHeader() { verticalAlignment = Alignment.CenterVertically ) { Text( - text = "YOUR NETWORK", + text = stringResource(id = R.string.your_network).uppercase(), style = MaterialTheme.typography.titleMedium.copy( fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace @@ -175,7 +175,7 @@ fun ChannelsSection( ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = "CHANNELS", + text = stringResource(id = R.string.channels).uppercase(), style = MaterialTheme.typography.labelSmall, color = colorScheme.onSurface.copy(alpha = 0.6f), fontWeight = FontWeight.Bold @@ -255,7 +255,7 @@ fun PeopleSection( ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = "PEOPLE", + text = stringResource(id = R.string.people).uppercase(), style = MaterialTheme.typography.labelSmall, color = colorScheme.onSurface.copy(alpha = 0.6f), fontWeight = FontWeight.Bold @@ -264,7 +264,7 @@ fun PeopleSection( if (connectedPeers.isEmpty()) { Text( - text = "No one connected", + text = stringResource(id = R.string.no_one_connected), style = MaterialTheme.typography.bodyMedium, color = colorScheme.onSurface.copy(alpha = 0.5f), modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 51c0a712..82b84804 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,4 +16,5 @@ Online Users No one connected Triple tap to clear all data + Your Network From d730acbb79f76790fede54aed862de48dec7251b Mon Sep 17 00:00:00 2001 From: Mohamad Hamade Date: Wed, 16 Jul 2025 01:13:42 +0300 Subject: [PATCH 5/6] Refactor: Migrate MainViewModel to use StateFlow This commit refactors the `MainViewModel` to use `StateFlow` instead of `mutableStateOf` for managing its observable properties. This change improves the way UI state is handled and observed, aligning with modern Android development best practices. Additionally, `OnboardingState.kt` has been moved to the `onboarding` package for better organization. --- .../java/com/bitchat/android/MainActivity.kt | 67 ++++++++++++++----- .../java/com/bitchat/android/MainViewModel.kt | 65 +++++++++--------- .../{ => onboarding}/OnboardingState.kt | 2 +- 3 files changed, 86 insertions(+), 48 deletions(-) rename app/src/main/java/com/bitchat/android/{ => onboarding}/OnboardingState.kt (82%) diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 232fca28..44318781 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -10,9 +10,13 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.Lifecycle import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.onboarding.BluetoothCheckScreen import com.bitchat.android.onboarding.BluetoothStatus @@ -23,6 +27,7 @@ import com.bitchat.android.onboarding.LocationCheckScreen import com.bitchat.android.onboarding.LocationStatus import com.bitchat.android.onboarding.LocationStatusManager import com.bitchat.android.onboarding.OnboardingCoordinator +import com.bitchat.android.onboarding.OnboardingState import com.bitchat.android.onboarding.PermissionExplanationScreen import com.bitchat.android.onboarding.PermissionManager import com.bitchat.android.ui.ChatScreen @@ -88,23 +93,39 @@ class MainActivity : ComponentActivity() { } } + // Collect state changes in a lifecycle-aware manner + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + mainViewModel.onboardingState.collect { state -> + handleOnboardingStateChange(state) + } + } + } + // Only start onboarding process if we're in the initial CHECKING state // This prevents restarting onboarding on configuration changes - if (mainViewModel.onboardingState == OnboardingState.CHECKING) { + if (mainViewModel.onboardingState.value == OnboardingState.CHECKING) { checkOnboardingStatus() } } @Composable private fun OnboardingFlowScreen() { - when (mainViewModel.onboardingState) { + val onboardingState by mainViewModel.onboardingState.collectAsState() + val bluetoothStatus by mainViewModel.bluetoothStatus.collectAsState() + val locationStatus by mainViewModel.locationStatus.collectAsState() + val errorMessage by mainViewModel.errorMessage.collectAsState() + val isBluetoothLoading by mainViewModel.isBluetoothLoading.collectAsState() + val isLocationLoading by mainViewModel.isLocationLoading.collectAsState() + + when (onboardingState) { OnboardingState.CHECKING -> { InitializingScreen() } OnboardingState.BLUETOOTH_CHECK -> { BluetoothCheckScreen( - status = mainViewModel.bluetoothStatus, + status = bluetoothStatus, onEnableBluetooth = { mainViewModel.updateBluetoothLoading(true) bluetoothStatusManager.requestEnableBluetooth() @@ -112,13 +133,13 @@ class MainActivity : ComponentActivity() { onRetry = { checkBluetoothAndProceed() }, - isLoading = mainViewModel.isBluetoothLoading + isLoading = isBluetoothLoading ) } OnboardingState.LOCATION_CHECK -> { LocationCheckScreen( - status = mainViewModel.locationStatus, + status = locationStatus, onEnableLocation = { mainViewModel.updateLocationLoading(true) locationStatusManager.requestEnableLocation() @@ -126,7 +147,7 @@ class MainActivity : ComponentActivity() { onRetry = { checkLocationAndProceed() }, - isLoading = mainViewModel.isLocationLoading + isLoading = isLocationLoading ) } @@ -172,7 +193,7 @@ class MainActivity : ComponentActivity() { OnboardingState.ERROR -> { InitializationErrorScreen( - errorMessage = mainViewModel.errorMessage, + errorMessage = errorMessage, onRetry = { mainViewModel.updateOnboardingState(OnboardingState.CHECKING) checkOnboardingStatus() @@ -185,6 +206,20 @@ class MainActivity : ComponentActivity() { } } + private fun handleOnboardingStateChange(state: OnboardingState) { + + when (state) { + OnboardingState.COMPLETE -> { + // App is fully initialized, mesh service is running + android.util.Log.d("MainActivity", "Onboarding completed - app ready") + } + OnboardingState.ERROR -> { + android.util.Log.e("MainActivity", "Onboarding error state reached") + } + else -> {} + } + } + private fun checkOnboardingStatus() { android.util.Log.d("MainActivity", "Checking onboarding status") @@ -215,7 +250,7 @@ class MainActivity : ComponentActivity() { bluetoothStatusManager.logBluetoothStatus() mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus()) - when (mainViewModel.bluetoothStatus) { + when (mainViewModel.bluetoothStatus.value) { BluetoothStatus.ENABLED -> { // Bluetooth is enabled, check location services next checkLocationAndProceed() @@ -286,7 +321,7 @@ class MainActivity : ComponentActivity() { locationStatusManager.logLocationStatus() mainViewModel.updateLocationStatus(locationStatusManager.checkLocationStatus()) - when (mainViewModel.locationStatus) { + when (mainViewModel.locationStatus.value) { LocationStatus.ENABLED -> { // Location services enabled, proceed with permission/onboarding check proceedWithPermissionCheck() @@ -325,7 +360,7 @@ class MainActivity : ComponentActivity() { mainViewModel.updateLocationStatus(locationStatusManager.checkLocationStatus()) when { - mainViewModel.locationStatus == LocationStatus.NOT_AVAILABLE -> { + mainViewModel.locationStatus.value == LocationStatus.NOT_AVAILABLE -> { // Show permanent error for devices without location services mainViewModel.updateErrorMessage(message) mainViewModel.updateOnboardingState(OnboardingState.ERROR) @@ -346,7 +381,7 @@ class MainActivity : ComponentActivity() { mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus()) when { - mainViewModel.bluetoothStatus == BluetoothStatus.NOT_SUPPORTED -> { + mainViewModel.bluetoothStatus.value == BluetoothStatus.NOT_SUPPORTED -> { // Show permanent error for unsupported devices mainViewModel.updateErrorMessage(message) mainViewModel.updateOnboardingState(OnboardingState.ERROR) @@ -450,7 +485,7 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // Handle notification intents when app is already running - if (mainViewModel.onboardingState == OnboardingState.COMPLETE) { + if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { handleNotificationIntent(intent) } } @@ -458,7 +493,7 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() // Check Bluetooth and Location status on resume and handle accordingly - if (mainViewModel.onboardingState == OnboardingState.COMPLETE) { + if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { // Set app foreground state meshService.connectionManager.setAppBackgroundState(false) chatViewModel.setAppBackgroundState(false) @@ -487,7 +522,7 @@ class MainActivity : ComponentActivity() { override fun onPause() { super.onPause() // Only set background state if app is fully initialized - if (mainViewModel.onboardingState == OnboardingState.COMPLETE) { + if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { // Set app background state meshService.connectionManager.setAppBackgroundState(true) chatViewModel.setAppBackgroundState(true) @@ -523,7 +558,7 @@ class MainActivity : ComponentActivity() { * Restart mesh services (for debugging/troubleshooting) */ fun restartMeshServices() { - if (mainViewModel.onboardingState == OnboardingState.COMPLETE) { + if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { lifecycleScope.launch { try { android.util.Log.d("MainActivity", "Restarting mesh services") @@ -550,7 +585,7 @@ class MainActivity : ComponentActivity() { } // Stop mesh services if app was fully initialized - if (mainViewModel.onboardingState == OnboardingState.COMPLETE) { + if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { try { meshService.stopServices() android.util.Log.d("MainActivity", "Mesh services stopped successfully") diff --git a/app/src/main/java/com/bitchat/android/MainViewModel.kt b/app/src/main/java/com/bitchat/android/MainViewModel.kt index 61e13d63..62177c18 100644 --- a/app/src/main/java/com/bitchat/android/MainViewModel.kt +++ b/app/src/main/java/com/bitchat/android/MainViewModel.kt @@ -1,52 +1,55 @@ package com.bitchat.android -import androidx.compose.runtime.* import androidx.lifecycle.ViewModel import com.bitchat.android.onboarding.BluetoothStatus import com.bitchat.android.onboarding.LocationStatus +import com.bitchat.android.onboarding.OnboardingState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow class MainViewModel : ViewModel() { - - private var _onboardingState by mutableStateOf(OnboardingState.CHECKING) - val onboardingState: OnboardingState get() = _onboardingState - - private var _bluetoothStatus by mutableStateOf(BluetoothStatus.ENABLED) - val bluetoothStatus: BluetoothStatus get() = _bluetoothStatus - - private var _locationStatus by mutableStateOf(LocationStatus.ENABLED) - val locationStatus: LocationStatus get() = _locationStatus - - private var _errorMessage by mutableStateOf("") - val errorMessage: String get() = _errorMessage - - private var _isBluetoothLoading by mutableStateOf(false) - val isBluetoothLoading: Boolean get() = _isBluetoothLoading - - private var _isLocationLoading by mutableStateOf(false) - val isLocationLoading: Boolean get() = _isLocationLoading - + + private val _onboardingState = MutableStateFlow(OnboardingState.CHECKING) + val onboardingState: StateFlow = _onboardingState.asStateFlow() + + private val _bluetoothStatus = MutableStateFlow(BluetoothStatus.ENABLED) + val bluetoothStatus: StateFlow = _bluetoothStatus.asStateFlow() + + private val _locationStatus = MutableStateFlow(LocationStatus.ENABLED) + val locationStatus: StateFlow = _locationStatus.asStateFlow() + + private val _errorMessage = MutableStateFlow("") + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + private val _isBluetoothLoading = MutableStateFlow(false) + val isBluetoothLoading: StateFlow = _isBluetoothLoading.asStateFlow() + + private val _isLocationLoading = MutableStateFlow(false) + val isLocationLoading: StateFlow = _isLocationLoading.asStateFlow() + // Public update functions for MainActivity fun updateOnboardingState(state: OnboardingState) { - _onboardingState = state + _onboardingState.value = state } - + fun updateBluetoothStatus(status: BluetoothStatus) { - _bluetoothStatus = status + _bluetoothStatus.value = status } - + fun updateLocationStatus(status: LocationStatus) { - _locationStatus = status + _locationStatus.value = status } - + fun updateErrorMessage(message: String) { - _errorMessage = message + _errorMessage.value = message } - + fun updateBluetoothLoading(loading: Boolean) { - _isBluetoothLoading = loading + _isBluetoothLoading.value = loading } - + fun updateLocationLoading(loading: Boolean) { - _isLocationLoading = loading + _isLocationLoading.value = loading } } \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/OnboardingState.kt b/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt similarity index 82% rename from app/src/main/java/com/bitchat/android/OnboardingState.kt rename to app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt index a5a1e83b..cb8e73b5 100644 --- a/app/src/main/java/com/bitchat/android/OnboardingState.kt +++ b/app/src/main/java/com/bitchat/android/onboarding/OnboardingState.kt @@ -1,4 +1,4 @@ -package com.bitchat.android +package com.bitchat.android.onboarding enum class OnboardingState { CHECKING, From 0611baa681a53aea3af6bdf4431e6191ec447bc4 Mon Sep 17 00:00:00 2001 From: Mohamad Hamade Date: Thu, 17 Jul 2025 13:49:31 +0300 Subject: [PATCH 6/6] fix: center align toolbar item in ChatHeader - passed modifier.fillmaxHeight so the content inside the row can actually be centered --- app/src/main/java/com/bitchat/android/ui/ChatHeader.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 65e2ee8e..895e3fc6 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -360,7 +360,10 @@ private fun MainHeader( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + modifier = Modifier.fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically + ) { Text( text = "bitchat*", style = MaterialTheme.typography.headlineSmall,