diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt new file mode 100644 index 000000000..96f408bc0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt @@ -0,0 +1,34 @@ +package com.bitchat.android.core.ui.component.button + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun CloseButton( + onClick: () -> Unit, + modifier: Modifier = Modifier.Companion +) { + IconButton( + onClick = onClick, + modifier = modifier + .size(32.dp), + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f) + ) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + modifier = Modifier.Companion.size(18.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt index b498dd833..d4ccb9bf3 100644 --- a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt +++ b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt @@ -5,13 +5,14 @@ import android.location.Geocoder import android.location.Location import android.location.LocationManager import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import com.google.gson.Gson import com.google.gson.reflect.TypeToken import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import java.util.Locale /** @@ -46,11 +47,11 @@ class GeohashBookmarksStore private constructor(private val context: Context) { private val membership = mutableSetOf() - private val _bookmarks = MutableLiveData>(emptyList()) - val bookmarks: LiveData> = _bookmarks + private val _bookmarks = MutableStateFlow>(emptyList()) + val bookmarks: StateFlow> = _bookmarks.asStateFlow() - private val _bookmarkNames = MutableLiveData>(emptyMap()) - val bookmarkNames: LiveData> = _bookmarkNames + private val _bookmarkNames = MutableStateFlow>(emptyMap()) + val bookmarkNames: StateFlow> = _bookmarkNames.asStateFlow() // For throttling / preventing duplicate geocode lookups private val resolving = mutableSetOf() @@ -68,8 +69,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) { val gh = normalize(geohash) if (gh.isEmpty() || membership.contains(gh)) return membership.add(gh) - val updated = listOf(gh) + (_bookmarks.value ?: emptyList()) - _bookmarks.postValue(updated) + val updated = listOf(gh) + (_bookmarks.value) + _bookmarks.value = updated persist(updated) // Resolve friendly name asynchronously resolveNameIfNeeded(gh) @@ -79,12 +80,12 @@ class GeohashBookmarksStore private constructor(private val context: Context) { val gh = normalize(geohash) if (!membership.contains(gh)) return membership.remove(gh) - val updated = (_bookmarks.value ?: emptyList()).filterNot { it == gh } - _bookmarks.postValue(updated) + val updated = (_bookmarks.value).filterNot { it == gh } + _bookmarks.value = updated // Remove stored name to avoid stale cache growth - val names = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf() + val names = _bookmarkNames.value.toMutableMap() if (names.remove(gh) != null) { - _bookmarkNames.postValue(names) + _bookmarkNames.value = names persistNames(names) } persist(updated) @@ -108,7 +109,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) { } } membership.clear(); membership.addAll(seen) - _bookmarks.postValue(ordered) + _bookmarks.value = ordered } } catch (e: Exception) { Log.e(TAG, "Failed to load bookmarks: ${e.message}") @@ -118,7 +119,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) { if (!namesJson.isNullOrEmpty()) { val mapType = object : TypeToken>() {}.type val dict = gson.fromJson>(namesJson, mapType) - _bookmarkNames.postValue(dict) + _bookmarkNames.value = dict } } catch (e: Exception) { Log.e(TAG, "Failed to load bookmark names: ${e.message}") @@ -127,14 +128,14 @@ class GeohashBookmarksStore private constructor(private val context: Context) { private fun persist() { try { - val json = gson.toJson(_bookmarks.value ?: emptyList()) + val json = gson.toJson(_bookmarks.value) prefs.edit().putString(STORE_KEY, json).apply() } catch (_: Exception) {} } private fun persistNames() { try { - val json = gson.toJson(_bookmarkNames.value ?: emptyMap()) + val json = gson.toJson(_bookmarkNames.value) prefs.edit().putString(NAMES_STORE_KEY, json).apply() } catch (_: Exception) {} } @@ -144,8 +145,8 @@ class GeohashBookmarksStore private constructor(private val context: Context) { fun clearAll() { try { membership.clear() - _bookmarks.postValue(emptyList()) - _bookmarkNames.postValue(emptyMap()) + _bookmarks.value = emptyList() + _bookmarkNames.value = emptyMap() prefs.edit() .remove(STORE_KEY) .remove(NAMES_STORE_KEY) @@ -209,9 +210,9 @@ class GeohashBookmarksStore private constructor(private val context: Context) { } if (!name.isNullOrEmpty()) { - val current = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf() + val current = _bookmarkNames.value.toMutableMap() current[gh] = name - _bookmarkNames.postValue(current) + _bookmarkNames.value = current persistNames(current) } } catch (e: Exception) { diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt index b8da1fee3..9c18a859d 100644 --- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt @@ -10,12 +10,12 @@ import android.location.LocationManager import android.os.Bundle import android.util.Log import androidx.core.app.ActivityCompat -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.* import java.util.* import com.google.gson.Gson import com.google.gson.JsonSyntaxException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow /** * Manages location permissions, one-shot location retrieval, and computing geohash channels. @@ -53,28 +53,26 @@ class LocationChannelManager private constructor(private val context: Context) { private var dataManager: com.bitchat.android.ui.DataManager? = null // Published state for UI bindings (matching iOS @Published properties) - private val _permissionState = MutableLiveData(PermissionState.NOT_DETERMINED) - val permissionState: LiveData = _permissionState + private val _permissionState = MutableStateFlow(PermissionState.NOT_DETERMINED) + val permissionState: StateFlow = _permissionState - private val _availableChannels = MutableLiveData>(emptyList()) - val availableChannels: LiveData> = _availableChannels + private val _availableChannels = MutableStateFlow>(emptyList()) + val availableChannels: StateFlow> = _availableChannels - private val _selectedChannel = MutableLiveData(ChannelID.Mesh) - val selectedChannel: LiveData = _selectedChannel + private val _selectedChannel = MutableStateFlow(ChannelID.Mesh) + val selectedChannel: StateFlow = _selectedChannel - private val _teleported = MutableLiveData(false) - val teleported: LiveData = _teleported + private val _teleported = MutableStateFlow(false) + val teleported: StateFlow = _teleported - private val _locationNames = MutableLiveData>(emptyMap()) - val locationNames: LiveData> = _locationNames + private val _locationNames = MutableStateFlow>(emptyMap()) + val locationNames: StateFlow> = _locationNames - // Add a new LiveData property to indicate when location is being fetched - private val _isLoadingLocation = MutableLiveData(false) - val isLoadingLocation: LiveData = _isLoadingLocation + private val _isLoadingLocation = MutableStateFlow(false) + val isLoadingLocation: StateFlow = _isLoadingLocation - // Add a new LiveData property to track if location services are enabled by user - private val _locationServicesEnabled = MutableLiveData(false) - val locationServicesEnabled: LiveData = _locationServicesEnabled + private val _locationServicesEnabled = MutableStateFlow(false) + val locationServicesEnabled: StateFlow = _locationServicesEnabled init { updatePermissionState() @@ -102,15 +100,15 @@ class LocationChannelManager private constructor(private val context: Context) { when (getCurrentPermissionStatus()) { PermissionState.NOT_DETERMINED -> { Log.d(TAG, "Permission not determined - user needs to grant in app settings") - _permissionState.postValue(PermissionState.NOT_DETERMINED) + _permissionState.value = PermissionState.NOT_DETERMINED } PermissionState.DENIED, PermissionState.RESTRICTED -> { Log.d(TAG, "Permission denied or restricted") - _permissionState.postValue(PermissionState.DENIED) + _permissionState.value = PermissionState.DENIED } PermissionState.AUTHORIZED -> { Log.d(TAG, "Permission authorized - requesting location") - _permissionState.postValue(PermissionState.AUTHORIZED) + _permissionState.value = PermissionState.AUTHORIZED requestOneShotLocation() } } @@ -180,7 +178,7 @@ class LocationChannelManager private constructor(private val context: Context) { lastLocation?.let { location -> when (channel) { is ChannelID.Mesh -> { - _teleported.postValue(false) + _teleported.value = false } is ChannelID.Location -> { val currentGeohash = Geohash.encode( @@ -189,7 +187,7 @@ class LocationChannelManager private constructor(private val context: Context) { precision = channel.channel.level.precision ) val isTeleportedNow = currentGeohash != channel.channel.geohash - _teleported.postValue(isTeleportedNow) + _teleported.value = isTeleportedNow Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})") } } @@ -201,7 +199,7 @@ class LocationChannelManager private constructor(private val context: Context) { */ fun setTeleported(teleported: Boolean) { Log.d(TAG, "Setting teleported status: $teleported") - _teleported.postValue(teleported) + _teleported.value = teleported } /** @@ -209,7 +207,7 @@ class LocationChannelManager private constructor(private val context: Context) { */ fun enableLocationServices() { Log.d(TAG, "enableLocationServices() called by user") - _locationServicesEnabled.postValue(true) + _locationServicesEnabled.value = true saveLocationServicesState(true) // If we have permission, start location operations @@ -223,15 +221,15 @@ class LocationChannelManager private constructor(private val context: Context) { */ fun disableLocationServices() { Log.d(TAG, "disableLocationServices() called by user") - _locationServicesEnabled.postValue(false) + _locationServicesEnabled.value = false saveLocationServicesState(false) // Stop any ongoing location operations endLiveRefresh() // Clear available channels when location is disabled - _availableChannels.postValue(emptyList()) - _locationNames.postValue(emptyMap()) + _availableChannels.value = emptyList() + _locationNames.value = emptyMap() // If user had a location channel selected, switch back to mesh if (_selectedChannel.value is ChannelID.Location) { @@ -243,7 +241,7 @@ class LocationChannelManager private constructor(private val context: Context) { * Check if location services are enabled by the user */ fun isLocationServicesEnabled(): Boolean { - return _locationServicesEnabled.value ?: false + return _locationServicesEnabled.value } // MARK: - Location Operations @@ -280,13 +278,13 @@ class LocationChannelManager private constructor(private val context: Context) { if (lastKnownLocation != null) { Log.d(TAG, "Using last known location: ${lastKnownLocation.latitude}, ${lastKnownLocation.longitude}") lastLocation = lastKnownLocation - _isLoadingLocation.postValue(false) // Make sure loading state is off + _isLoadingLocation.value = false // Make sure loading state is off computeChannels(lastKnownLocation) reverseGeocodeIfNeeded(lastKnownLocation) } else { Log.d(TAG, "No last known location available") // Set loading state to true so UI can show a spinner - _isLoadingLocation.postValue(true) + _isLoadingLocation.value = true // Request a fresh location only when we don't have a last known location Log.d(TAG, "Requesting fresh location...") @@ -294,7 +292,7 @@ class LocationChannelManager private constructor(private val context: Context) { } } catch (e: SecurityException) { Log.e(TAG, "Security exception requesting location: ${e.message}") - _isLoadingLocation.postValue(false) // Turn off loading state on error + _isLoadingLocation.value = false // Turn off loading state on error updatePermissionState() } } @@ -308,7 +306,7 @@ class LocationChannelManager private constructor(private val context: Context) { reverseGeocodeIfNeeded(location) // Update loading state to indicate we have a location now - _isLoadingLocation.postValue(false) + _isLoadingLocation.value = false // Remove this listener after getting the update try { @@ -322,13 +320,13 @@ class LocationChannelManager private constructor(private val context: Context) { // Request a fresh location update using getCurrentLocation instead of continuous updates private fun requestFreshLocation() { if (!hasLocationPermission()) { - _isLoadingLocation.postValue(false) // Turn off loading state if no permission + _isLoadingLocation.value = false // Turn off loading state if no permission return } try { // Set loading state to true to indicate we're actively trying to get a location - _isLoadingLocation.postValue(true) + _isLoadingLocation.value = true // Try common providers in order of preference val providers = listOf( @@ -358,7 +356,7 @@ class LocationChannelManager private constructor(private val context: Context) { Log.w(TAG, "Received null location from getCurrentLocation") } // Update loading state to indicate we have a location now - _isLoadingLocation.postValue(false) + _isLoadingLocation.value = false } ) } else { @@ -378,14 +376,14 @@ class LocationChannelManager private constructor(private val context: Context) { // If no provider was available, turn off loading state if (!providerFound) { Log.w(TAG, "No location providers available") - _isLoadingLocation.postValue(false) + _isLoadingLocation.value = false } } catch (e: SecurityException) { Log.e(TAG, "Security exception requesting location: ${e.message}") - _isLoadingLocation.postValue(false) // Turn off loading state on error + _isLoadingLocation.value = false // Turn off loading state on error } catch (e: Exception) { Log.e(TAG, "Error requesting location: ${e.message}") - _isLoadingLocation.postValue(false) // Turn off loading state on error + _isLoadingLocation.value = false // Turn off loading state on error } } @@ -408,7 +406,7 @@ class LocationChannelManager private constructor(private val context: Context) { private fun updatePermissionState() { val newState = getCurrentPermissionStatus() Log.d(TAG, "Permission state updated to: $newState") - _permissionState.postValue(newState) + _permissionState.value = newState } private fun hasLocationPermission(): Boolean { @@ -433,13 +431,13 @@ class LocationChannelManager private constructor(private val context: Context) { Log.v(TAG, "Generated ${level.displayName}: $geohash") } - _availableChannels.postValue(result) + _availableChannels.value = result // Recompute teleported status based on current location vs selected channel val selectedChannelValue = _selectedChannel.value when (selectedChannelValue) { is ChannelID.Mesh -> { - _teleported.postValue(false) + _teleported.value = false } is ChannelID.Location -> { val currentGeohash = Geohash.encode( @@ -448,12 +446,9 @@ class LocationChannelManager private constructor(private val context: Context) { precision = selectedChannelValue.channel.level.precision ) val isTeleported = currentGeohash != selectedChannelValue.channel.geohash - _teleported.postValue(isTeleported) + _teleported.value = isTeleported Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})") } - null -> { - _teleported.postValue(false) - } } } @@ -482,7 +477,7 @@ class LocationChannelManager private constructor(private val context: Context) { val names = namesByLevel(address) Log.d(TAG, "Reverse geocoding result: $names") - _locationNames.postValue(names) + _locationNames.value = names } else { Log.w(TAG, "No reverse geocoding results") } @@ -601,26 +596,26 @@ class LocationChannelManager private constructor(private val context: Context) { } if (channel != null) { - _selectedChannel.postValue(channel) + _selectedChannel.value = channel Log.d(TAG, "Restored persisted channel: ${channel.displayName}") } else { Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh") - _selectedChannel.postValue(ChannelID.Mesh) + _selectedChannel.value = ChannelID.Mesh } } else { Log.w(TAG, "Invalid channel data format in persistence") - _selectedChannel.postValue(ChannelID.Mesh) + _selectedChannel.value = ChannelID.Mesh } } else { Log.d(TAG, "No persisted channel found, defaulting to Mesh") - _selectedChannel.postValue(ChannelID.Mesh) + _selectedChannel.value = ChannelID.Mesh } } catch (e: JsonSyntaxException) { Log.e(TAG, "Failed to parse persisted channel data: ${e.message}") - _selectedChannel.postValue(ChannelID.Mesh) + _selectedChannel.value = ChannelID.Mesh } catch (e: Exception) { Log.e(TAG, "Failed to load persisted channel: ${e.message}") - _selectedChannel.postValue(ChannelID.Mesh) + _selectedChannel.value = ChannelID.Mesh } } @@ -629,7 +624,7 @@ class LocationChannelManager private constructor(private val context: Context) { */ fun clearPersistedChannel() { dataManager?.clearLastGeohashChannel() - _selectedChannel.postValue(ChannelID.Mesh) + _selectedChannel.value = ChannelID.Mesh Log.d(TAG, "Cleared persisted channel selection") } @@ -653,11 +648,11 @@ class LocationChannelManager private constructor(private val context: Context) { private fun loadLocationServicesState() { try { val enabled = dataManager?.isLocationServicesEnabled() ?: false - _locationServicesEnabled.postValue(enabled) + _locationServicesEnabled.value = enabled Log.d(TAG, "Loaded location services state: $enabled") } catch (e: Exception) { Log.e(TAG, "Failed to load location services state: ${e.message}") - _locationServicesEnabled.postValue(false) + _locationServicesEnabled.value = false } } diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt index c72c8e679..822606c2a 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt @@ -2,7 +2,6 @@ package com.bitchat.android.nostr import android.app.Application import android.util.Log -import androidx.lifecycle.LiveData import com.bitchat.android.ui.ChatState import com.bitchat.android.ui.GeoPerson import java.util.Date @@ -112,7 +111,7 @@ class GeohashRepository( val geohash = currentGeohash if (geohash == null) { // Use postValue for thread safety - this can be called from background threads - state.postGeohashPeople(emptyList()) + state.setGeohashPeople(emptyList()) return } val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) @@ -143,7 +142,7 @@ class GeohashRepository( ) }.sortedByDescending { it.lastSeen } // Use postValue for thread safety - this can be called from background threads - state.postGeohashPeople(people) + state.setGeohashPeople(people) } fun updateReactiveParticipantCounts() { @@ -155,7 +154,7 @@ class GeohashRepository( counts[gh] = active } // Use postValue for thread safety - this can be called from background threads - state.postGeohashParticipantCounts(counts) + state.setGeohashParticipantCounts(counts) } fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) { diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt index 655a97834..dc1a8e85b 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -2,13 +2,14 @@ package com.bitchat.android.nostr import android.util.Log import androidx.annotation.MainThread -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow /** * Manages location notes (kind=1 text notes with geohash tags) - * iOS-compatible implementation with LiveData for Android UI binding + * iOS-compatible implementation with StateFlow for Android UI binding */ @MainThread class LocationNotesManager private constructor() { @@ -63,21 +64,21 @@ class LocationNotesManager private constructor() { NO_RELAYS } - // Published state (LiveData for Android) - private val _notes = MutableLiveData>(emptyList()) - val notes: LiveData> = _notes + // Published state (StateFlow for Android) + private val _notes = MutableStateFlow>(emptyList()) + val notes: StateFlow> = _notes.asStateFlow() - private val _geohash = MutableLiveData(null) - val geohash: LiveData = _geohash + private val _geohash = MutableStateFlow(null) + val geohash: StateFlow = _geohash.asStateFlow() - private val _initialLoadComplete = MutableLiveData(false) - val initialLoadComplete: LiveData = _initialLoadComplete + private val _initialLoadComplete = MutableStateFlow(false) + val initialLoadComplete: StateFlow = _initialLoadComplete.asStateFlow() - private val _state = MutableLiveData(State.IDLE) - val state: LiveData = _state + private val _state = MutableStateFlow(State.IDLE) + val state: StateFlow = _state.asStateFlow() - private val _errorMessage = MutableLiveData(null) - val errorMessage: LiveData = _errorMessage + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() // Private state private var subscriptionIDs: MutableMap = mutableMapOf() diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt index 9e34c34c9..a48031574 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt @@ -2,9 +2,10 @@ package com.bitchat.android.nostr import android.content.Context import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow /** * High-level Nostr client that manages identity, connections, and messaging @@ -30,11 +31,11 @@ class NostrClient private constructor(private val context: Context) { private var currentIdentity: NostrIdentity? = null // Client state - private val _isInitialized = MutableLiveData() - val isInitialized: LiveData = _isInitialized + private val _isInitialized = MutableStateFlow(false) + val isInitialized: StateFlow = _isInitialized.asStateFlow() - private val _currentNpub = MutableLiveData() - val currentNpub: LiveData = _currentNpub + private val _currentNpub = MutableStateFlow(null) + val currentNpub: StateFlow = _currentNpub.asStateFlow() // Message processing private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) @@ -53,21 +54,21 @@ class NostrClient private constructor(private val context: Context) { currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context) if (currentIdentity != null) { - _currentNpub.postValue(currentIdentity!!.npub) + _currentNpub.value = currentIdentity!!.npub Log.i(TAG, "✅ Nostr identity loaded: ${currentIdentity!!.getShortNpub()}") // Connect to relays relayManager.connect() - _isInitialized.postValue(true) + _isInitialized.value = true Log.i(TAG, "✅ Nostr client initialized successfully") } else { Log.e(TAG, "❌ Failed to load/create Nostr identity") - _isInitialized.postValue(false) + _isInitialized.value = false } } catch (e: Exception) { Log.e(TAG, "❌ Failed to initialize Nostr client: ${e.message}") - _isInitialized.postValue(false) + _isInitialized.value = false } } } @@ -78,7 +79,7 @@ class NostrClient private constructor(private val context: Context) { fun shutdown() { Log.d(TAG, "Shutting down Nostr client") relayManager.disconnect() - _isInitialized.postValue(false) + _isInitialized.value = false } /** @@ -227,12 +228,12 @@ class NostrClient private constructor(private val context: Context) { /** * Get relay connection status */ - val relayConnectionStatus: LiveData = relayManager.isConnected + val relayConnectionStatus: StateFlow = relayManager.isConnected /** * Get relay information */ - val relayInfo: LiveData> = relayManager.relays + val relayInfo: StateFlow> = relayManager.relays // MARK: - Private Methods diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index eb5806bb5..d44e6e0bb 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -1,9 +1,10 @@ package com.bitchat.android.nostr import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import com.google.gson.Gson +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import com.google.gson.JsonArray import com.google.gson.JsonParser import kotlinx.coroutines.* @@ -72,11 +73,11 @@ class NostrRelayManager private constructor() { ) // Published state - private val _relays = MutableLiveData>() - val relays: LiveData> = _relays + private val _relays = MutableStateFlow>(emptyList()) + val relays: StateFlow> = _relays.asStateFlow() - private val _isConnected = MutableLiveData() - val isConnected: LiveData = _isConnected + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() // Internal state private val relaysList = mutableListOf() @@ -226,14 +227,14 @@ class NostrRelayManager private constructor() { "wss://nostr21.com" ) relaysList.addAll(defaultRelayUrls.map { Relay(it) }) - _relays.postValue(relaysList.toList()) + _relays.value = relaysList.toList() updateConnectionStatus() Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays") } catch (e: Exception) { Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e) // Initialize with empty list as fallback - _relays.postValue(emptyList()) - _isConnected.postValue(false) + _relays.value = emptyList() + _isConnected.value = false } } @@ -797,12 +798,12 @@ class NostrRelayManager private constructor() { } private fun updateRelaysList() { - _relays.postValue(relaysList.toList()) + _relays.value = relaysList.toList() } private fun updateConnectionStatus() { val connected = relaysList.any { it.isConnected } - _isConnected.postValue(connected) + _isConnected.value = connected } private fun generateSubscriptionId(): String { diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index b5dffa0f6..49c664d39 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -29,7 +29,9 @@ import androidx.compose.ui.unit.sp import com.bitchat.android.nostr.NostrProofOfWork import com.bitchat.android.nostr.PoWPreferenceManager import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R +import com.bitchat.android.core.ui.component.button.CloseButton import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager import com.bitchat.android.net.ArtiTorManager @@ -243,7 +245,7 @@ fun AboutSheet( .padding(horizontal = 24.dp) .padding(top = 24.dp, bottom = 8.dp) ) - val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() + val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsStateWithLifecycle() Row( modifier = Modifier.padding(horizontal = 24.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -279,8 +281,8 @@ fun AboutSheet( PoWPreferenceManager.init(context) } - val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() - val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() + val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle() + val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle() Column( modifier = Modifier.padding(horizontal = 24.dp), @@ -595,18 +597,12 @@ fun AboutSheet( .height(64.dp) .background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha)) ) { - TextButton( + CloseButton( onClick = onDismiss, - modifier = Modifier + modifier = modifier .align(Alignment.CenterEnd) - .padding(horizontal = 16.dp) - ) { - Text( - text = stringResource(R.string.close_plain), - style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.onBackground - ) - } + .padding(horizontal = 16.dp), + ) } } } 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 bed7e24d2..f741dafa8 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -14,7 +14,6 @@ import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -30,6 +29,7 @@ import androidx.compose.ui.unit.sp import com.bitchat.android.core.ui.utils.singleOrTripleClickable import androidx.compose.foundation.Canvas import androidx.compose.ui.geometry.Offset +import androidx.lifecycle.compose.collectAsStateWithLifecycle /** * Header components for ChatScreen @@ -248,10 +248,10 @@ fun ChatHeaderContent( when { selectedPrivatePeer != null -> { // Private chat header - Fully reactive state tracking - val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) - val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) - val peerSessionStates by viewModel.peerSessionStates.observeAsState(emptyMap()) - val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap()) + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() + val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle() + val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() // Reactive favorite computation - no more static lookups! val isFavorite = isFavoriteReactive( @@ -264,8 +264,8 @@ fun ChatHeaderContent( Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, isFav=$isFavorite, sessionState=$sessionState") // Pass geohash context and people for NIP-17 chat title formatting - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() - val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() PrivateChatHeader( peerID = selectedPrivatePeer, @@ -523,18 +523,18 @@ private fun MainHeader( viewModel: ChatViewModel ) { val colorScheme = MaterialTheme.colorScheme - val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) - val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet()) - val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap()) - val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) - val isConnected by viewModel.isConnected.observeAsState(false) - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() - val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) + val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle() + val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle() + val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() + val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() // Bookmarks store for current geohash toggle (iOS parity) val context = androidx.compose.ui.platform.LocalContext.current val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) } - val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList()) + val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle() Row( modifier = Modifier.fillMaxWidth(), @@ -653,8 +653,8 @@ private fun LocationChannelsButton( val colorScheme = MaterialTheme.colorScheme // Get current channel selection from location manager - val selectedChannel by viewModel.selectedLocationChannel.observeAsState() - val teleported by viewModel.isTeleported.observeAsState(false) + val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val teleported by viewModel.isTeleported.collectAsStateWithLifecycle() val (badgeText, badgeColor) = when (selectedChannel) { is com.bitchat.android.geohash.ChannelID.Mesh -> { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 6ec8bef7e..d4d33ac0e 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.Alignment @@ -25,6 +24,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.model.BitchatMessage import com.bitchat.android.ui.media.FullScreenImageViewer @@ -41,22 +41,22 @@ import com.bitchat.android.ui.media.FullScreenImageViewer @Composable fun ChatScreen(viewModel: ChatViewModel) { val colorScheme = MaterialTheme.colorScheme - val messages by viewModel.messages.observeAsState(emptyList()) - val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) - val nickname by viewModel.nickname.observeAsState("") - val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState() - val currentChannel by viewModel.currentChannel.observeAsState() - val joinedChannels by viewModel.joinedChannels.observeAsState(emptySet()) - val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap()) - val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) - val privateChats by viewModel.privateChats.observeAsState(emptyMap()) - val channelMessages by viewModel.channelMessages.observeAsState(emptyMap()) - val showSidebar by viewModel.showSidebar.observeAsState(false) - val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false) - val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList()) - val showMentionSuggestions by viewModel.showMentionSuggestions.observeAsState(false) - val mentionSuggestions by viewModel.mentionSuggestions.observeAsState(emptyList()) - val showAppInfo by viewModel.showAppInfo.observeAsState(false) + val messages by viewModel.messages.collectAsStateWithLifecycle() + val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() + val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle() + val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle() + val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle() + val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() + val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() + val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle() + val showSidebar by viewModel.showSidebar.collectAsStateWithLifecycle() + val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle() + val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle() + val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle() + val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle() + val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle() var messageText by remember { mutableStateOf(TextFieldValue("")) } var showPasswordPrompt by remember { mutableStateOf(false) } @@ -78,11 +78,11 @@ fun ChatScreen(viewModel: ChatViewModel) { showPasswordDialog = showPasswordPrompt } - val isConnected by viewModel.isConnected.observeAsState(false) - val passwordPromptChannel by viewModel.passwordPromptChannel.observeAsState(null) + val isConnected by viewModel.isConnected.collectAsStateWithLifecycle() + val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle() // Get location channel info for timeline switching - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() // Determine what messages to show based on current context (unified timelines) val displayMessages = when { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index e17c14943..6d0f2364e 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -1,10 +1,17 @@ package com.bitchat.android.ui import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.MutableLiveData import com.bitchat.android.model.BitchatMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn /** * Centralized state definitions and data classes for the chat system @@ -21,171 +28,162 @@ data class CommandSuggestion( /** * Contains all the observable state for the chat system */ -class ChatState { +class ChatState( + scope: CoroutineScope +) { // Core messages and peer state - private val _messages = MutableLiveData>(emptyList()) - val messages: LiveData> = _messages + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() - private val _connectedPeers = MutableLiveData>(emptyList()) - val connectedPeers: LiveData> = _connectedPeers + private val _connectedPeers = MutableStateFlow>(emptyList()) + val connectedPeers: StateFlow> = _connectedPeers.asStateFlow() - private val _nickname = MutableLiveData() - val nickname: LiveData = _nickname + private val _nickname = MutableStateFlow("") + val nickname: StateFlow = _nickname.asStateFlow() - private val _isConnected = MutableLiveData(false) - val isConnected: LiveData = _isConnected + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() // Private chats - private val _privateChats = MutableLiveData>>(emptyMap()) - val privateChats: LiveData>> = _privateChats + private val _privateChats = MutableStateFlow>>(emptyMap()) + val privateChats: StateFlow>> = _privateChats.asStateFlow() - private val _selectedPrivateChatPeer = MutableLiveData(null) - val selectedPrivateChatPeer: LiveData = _selectedPrivateChatPeer + private val _selectedPrivateChatPeer = MutableStateFlow(null) + val selectedPrivateChatPeer: StateFlow = _selectedPrivateChatPeer.asStateFlow() - private val _unreadPrivateMessages = MutableLiveData>(emptySet()) - val unreadPrivateMessages: LiveData> = _unreadPrivateMessages + private val _unreadPrivateMessages = MutableStateFlow>(emptySet()) + val unreadPrivateMessages: StateFlow> = _unreadPrivateMessages.asStateFlow() // Channels - private val _joinedChannels = MutableLiveData>(emptySet()) - val joinedChannels: LiveData> = _joinedChannels + private val _joinedChannels = MutableStateFlow>(emptySet()) + val joinedChannels: StateFlow> = _joinedChannels.asStateFlow() - private val _currentChannel = MutableLiveData(null) - val currentChannel: LiveData = _currentChannel + private val _currentChannel = MutableStateFlow(null) + val currentChannel: StateFlow = _currentChannel.asStateFlow() - private val _channelMessages = MutableLiveData>>(emptyMap()) - val channelMessages: LiveData>> = _channelMessages + private val _channelMessages = MutableStateFlow>>(emptyMap()) + val channelMessages: StateFlow>> = _channelMessages.asStateFlow() - private val _unreadChannelMessages = MutableLiveData>(emptyMap()) - val unreadChannelMessages: LiveData> = _unreadChannelMessages + private val _unreadChannelMessages = MutableStateFlow>(emptyMap()) + val unreadChannelMessages: StateFlow> = _unreadChannelMessages.asStateFlow() - private val _passwordProtectedChannels = MutableLiveData>(emptySet()) - val passwordProtectedChannels: LiveData> = _passwordProtectedChannels + private val _passwordProtectedChannels = MutableStateFlow>(emptySet()) + val passwordProtectedChannels: StateFlow> = _passwordProtectedChannels.asStateFlow() - private val _showPasswordPrompt = MutableLiveData(false) - val showPasswordPrompt: LiveData = _showPasswordPrompt + private val _showPasswordPrompt = MutableStateFlow(false) + val showPasswordPrompt: StateFlow = _showPasswordPrompt.asStateFlow() - private val _passwordPromptChannel = MutableLiveData(null) - val passwordPromptChannel: LiveData = _passwordPromptChannel + private val _passwordPromptChannel = MutableStateFlow(null) + val passwordPromptChannel: StateFlow = _passwordPromptChannel.asStateFlow() // Sidebar state - private val _showSidebar = MutableLiveData(false) - val showSidebar: LiveData = _showSidebar + private val _showSidebar = MutableStateFlow(false) + val showSidebar: StateFlow = _showSidebar.asStateFlow() // Command autocomplete - private val _showCommandSuggestions = MutableLiveData(false) - val showCommandSuggestions: LiveData = _showCommandSuggestions + private val _showCommandSuggestions = MutableStateFlow(false) + val showCommandSuggestions: StateFlow = _showCommandSuggestions.asStateFlow() - private val _commandSuggestions = MutableLiveData>(emptyList()) - val commandSuggestions: LiveData> = _commandSuggestions + private val _commandSuggestions = MutableStateFlow>(emptyList()) + val commandSuggestions: StateFlow> = _commandSuggestions.asStateFlow() // Mention autocomplete - private val _showMentionSuggestions = MutableLiveData(false) - val showMentionSuggestions: LiveData = _showMentionSuggestions + private val _showMentionSuggestions = MutableStateFlow(false) + val showMentionSuggestions: StateFlow = _showMentionSuggestions.asStateFlow() - private val _mentionSuggestions = MutableLiveData>(emptyList()) - val mentionSuggestions: LiveData> = _mentionSuggestions + private val _mentionSuggestions = MutableStateFlow>(emptyList()) + val mentionSuggestions: StateFlow> = _mentionSuggestions.asStateFlow() // Favorites - private val _favoritePeers = MutableLiveData>(emptySet()) - val favoritePeers: LiveData> = _favoritePeers + private val _favoritePeers = MutableStateFlow>(emptySet()) + val favoritePeers: StateFlow> = _favoritePeers.asStateFlow() // Noise session states for peers (for reactive UI updates) - private val _peerSessionStates = MutableLiveData>(emptyMap()) - val peerSessionStates: LiveData> = _peerSessionStates + private val _peerSessionStates = MutableStateFlow>(emptyMap()) + val peerSessionStates: StateFlow> = _peerSessionStates.asStateFlow() // Peer fingerprint state for reactive favorites (for reactive UI updates) - private val _peerFingerprints = MutableLiveData>(emptyMap()) - val peerFingerprints: LiveData> = _peerFingerprints + private val _peerFingerprints = MutableStateFlow>(emptyMap()) + val peerFingerprints: StateFlow> = _peerFingerprints.asStateFlow() - private val _peerNicknames = MutableLiveData>(emptyMap()) - val peerNicknames: LiveData> = _peerNicknames + private val _peerNicknames = MutableStateFlow>(emptyMap()) + val peerNicknames: StateFlow> = _peerNicknames.asStateFlow() - private val _peerRSSI = MutableLiveData>(emptyMap()) - val peerRSSI: LiveData> = _peerRSSI + private val _peerRSSI = MutableStateFlow>(emptyMap()) + val peerRSSI: StateFlow> = _peerRSSI.asStateFlow() // Direct connection status per peer (for live UI updates) - private val _peerDirect = MutableLiveData>(emptyMap()) - val peerDirect: LiveData> = _peerDirect + private val _peerDirect = MutableStateFlow>(emptyMap()) + val peerDirect: StateFlow> = _peerDirect.asStateFlow() // peerIDToPublicKeyFingerprint REMOVED - fingerprints now handled centrally in PeerManager // Navigation state - private val _showAppInfo = MutableLiveData(false) - val showAppInfo: LiveData = _showAppInfo + private val _showAppInfo = MutableStateFlow(false) + val showAppInfo: StateFlow = _showAppInfo.asStateFlow() // Location channels state (for Nostr geohash features) - private val _selectedLocationChannel = MutableLiveData(com.bitchat.android.geohash.ChannelID.Mesh) - val selectedLocationChannel: LiveData = _selectedLocationChannel + private val _selectedLocationChannel = MutableStateFlow(com.bitchat.android.geohash.ChannelID.Mesh) + val selectedLocationChannel: StateFlow = _selectedLocationChannel.asStateFlow() - private val _isTeleported = MutableLiveData(false) - val isTeleported: LiveData = _isTeleported + private val _isTeleported = MutableStateFlow(false) + val isTeleported: StateFlow = _isTeleported.asStateFlow() // Geohash people state (iOS-compatible) - private val _geohashPeople = MutableLiveData>(emptyList()) - val geohashPeople: LiveData> = _geohashPeople - // For background thread updates by repositories/handlers in their own scopes - val geohashPeopleMutable: MutableLiveData> get() = _geohashPeople + private val _geohashPeople = MutableStateFlow>(emptyList()) + val geohashPeople: StateFlow> = _geohashPeople.asStateFlow() - - private val _teleportedGeo = MutableLiveData>(emptySet()) - val teleportedGeo: LiveData> = _teleportedGeo + private val _teleportedGeo = MutableStateFlow>(emptySet()) + val teleportedGeo: StateFlow> = _teleportedGeo.asStateFlow() // Geohash participant counts reactive state (for real-time location channel counts) - private val _geohashParticipantCounts = MutableLiveData>(emptyMap()) - val geohashParticipantCounts: LiveData> = _geohashParticipantCounts - - // Unread state computed properties - val hasUnreadChannels: MediatorLiveData = MediatorLiveData() - val hasUnreadPrivateMessages: MediatorLiveData = MediatorLiveData() - - init { - // Initialize unread state mediators - hasUnreadChannels.addSource(_unreadChannelMessages) { unreadMap -> - hasUnreadChannels.value = unreadMap.values.any { it > 0 } - } - - hasUnreadPrivateMessages.addSource(_unreadPrivateMessages) { unreadSet -> - hasUnreadPrivateMessages.value = unreadSet.isNotEmpty() - } - } + private val _geohashParticipantCounts = MutableStateFlow>(emptyMap()) + val geohashParticipantCounts: StateFlow> = _geohashParticipantCounts.asStateFlow() + + + val hasUnreadChannels: StateFlow = _unreadChannelMessages + .map { unreadMap -> unreadMap.values.any { it > 0 } } + .stateIn( + scope = scope, + started = WhileSubscribed(5_000), + initialValue = false + ) + + val hasUnreadPrivateMessages: StateFlow = _unreadPrivateMessages + .map { unreadSet -> unreadSet.isNotEmpty() } + .stateIn( + scope = scope, + started = WhileSubscribed(5_000), + initialValue = false + ) // Getters for internal state access - fun getMessagesValue() = _messages.value ?: emptyList() - fun getConnectedPeersValue() = _connectedPeers.value ?: emptyList() + fun getMessagesValue() = _messages.value + fun getConnectedPeersValue() = _connectedPeers.value fun getNicknameValue() = _nickname.value - fun getPrivateChatsValue() = _privateChats.value ?: emptyMap() + fun getPrivateChatsValue() = _privateChats.value fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value - fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet() - fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet() - // Thread-safe posting helpers for background updates - fun postGeohashPeople(people: List) { - _geohashPeople.postValue(people) - } - - fun postGeohashParticipantCounts(counts: Map) { - _geohashParticipantCounts.postValue(counts) - } - - + fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value + fun getJoinedChannelsValue() = _joinedChannels.value fun getCurrentChannelValue() = _currentChannel.value - fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap() - fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap() - fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value ?: emptySet() - fun getShowPasswordPromptValue() = _showPasswordPrompt.value ?: false + fun getChannelMessagesValue() = _channelMessages.value + fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value + fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value + fun getShowPasswordPromptValue() = _showPasswordPrompt.value fun getPasswordPromptChannelValue() = _passwordPromptChannel.value - fun getShowSidebarValue() = _showSidebar.value ?: false - fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false - fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList() - fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value ?: false - fun getMentionSuggestionsValue() = _mentionSuggestions.value ?: emptyList() - fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet() - fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap() - fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap() - fun getShowAppInfoValue() = _showAppInfo.value ?: false - fun getGeohashPeopleValue() = _geohashPeople.value ?: emptyList() - fun getTeleportedGeoValue() = _teleportedGeo.value ?: emptySet() - fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value ?: emptyMap() + fun getShowSidebarValue() = _showSidebar.value + fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value + fun getCommandSuggestionsValue() = _commandSuggestions.value + fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value + fun getMentionSuggestionsValue() = _mentionSuggestions.value + fun getFavoritePeersValue() = _favoritePeers.value + fun getPeerSessionStatesValue() = _peerSessionStates.value + fun getPeerFingerprintsValue() = _peerFingerprints.value + fun getShowAppInfoValue() = _showAppInfo.value + fun getGeohashPeopleValue() = _geohashPeople.value + fun getTeleportedGeoValue() = _teleportedGeo.value + fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value // Setters for state updates fun setMessages(messages: List) { @@ -197,7 +195,7 @@ class ChatState { } fun postTeleportedGeo(teleported: Set) { - _teleportedGeo.postValue(teleported) + _teleportedGeo.value = teleported } fun setNickname(nickname: String) { @@ -269,7 +267,7 @@ class ChatState { } fun setFavoritePeers(favorites: Set) { - val currentValue = _favoritePeers.value ?: emptySet() + val currentValue = _favoritePeers.value Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites") Log.d("ChatState", "Current value: $currentValue") Log.d("ChatState", "Values equal: ${currentValue == favorites}") @@ -278,8 +276,7 @@ class ChatState { // Always set the value - even if equal, this ensures observers are triggered _favoritePeers.value = favorites - Log.d("ChatState", "LiveData value after set: ${_favoritePeers.value}") - Log.d("ChatState", "LiveData has active observers: ${_favoritePeers.hasActiveObservers()}") + Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}") } fun setPeerSessionStates(states: Map) { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 74ab15c67..7cf31d433 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -4,8 +4,8 @@ import android.app.Application import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.StateFlow import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage @@ -47,7 +47,9 @@ class ChatViewModel( } // MARK: - State management - private val state = ChatState() + private val state = ChatState( + scope = viewModelScope, + ) // Transfer progress tracking private val transferMessageMap = mutableMapOf() @@ -103,40 +105,40 @@ class ChatViewModel( - // Expose state through LiveData (maintaining the same interface) - val messages: LiveData> = state.messages - val connectedPeers: LiveData> = state.connectedPeers - val nickname: LiveData = state.nickname - val isConnected: LiveData = state.isConnected - val privateChats: LiveData>> = state.privateChats - val selectedPrivateChatPeer: LiveData = state.selectedPrivateChatPeer - val unreadPrivateMessages: LiveData> = state.unreadPrivateMessages - val joinedChannels: LiveData> = state.joinedChannels - val currentChannel: LiveData = state.currentChannel - val channelMessages: LiveData>> = state.channelMessages - val unreadChannelMessages: LiveData> = state.unreadChannelMessages - val passwordProtectedChannels: LiveData> = state.passwordProtectedChannels - val showPasswordPrompt: LiveData = state.showPasswordPrompt - val passwordPromptChannel: LiveData = state.passwordPromptChannel - val showSidebar: LiveData = state.showSidebar + + val messages: StateFlow> = state.messages + val connectedPeers: StateFlow> = state.connectedPeers + val nickname: StateFlow = state.nickname + val isConnected: StateFlow = state.isConnected + val privateChats: StateFlow>> = state.privateChats + val selectedPrivateChatPeer: StateFlow = state.selectedPrivateChatPeer + val unreadPrivateMessages: StateFlow> = state.unreadPrivateMessages + val joinedChannels: StateFlow> = state.joinedChannels + val currentChannel: StateFlow = state.currentChannel + val channelMessages: StateFlow>> = state.channelMessages + val unreadChannelMessages: StateFlow> = state.unreadChannelMessages + val passwordProtectedChannels: StateFlow> = state.passwordProtectedChannels + val showPasswordPrompt: StateFlow = state.showPasswordPrompt + val passwordPromptChannel: StateFlow = state.passwordPromptChannel + val showSidebar: StateFlow = state.showSidebar val hasUnreadChannels = state.hasUnreadChannels val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages - val showCommandSuggestions: LiveData = state.showCommandSuggestions - val commandSuggestions: LiveData> = state.commandSuggestions - val showMentionSuggestions: LiveData = state.showMentionSuggestions - val mentionSuggestions: LiveData> = state.mentionSuggestions - val favoritePeers: LiveData> = state.favoritePeers - val peerSessionStates: LiveData> = state.peerSessionStates - val peerFingerprints: LiveData> = state.peerFingerprints - val peerNicknames: LiveData> = state.peerNicknames - val peerRSSI: LiveData> = state.peerRSSI - val peerDirect: LiveData> = state.peerDirect - val showAppInfo: LiveData = state.showAppInfo - val selectedLocationChannel: LiveData = state.selectedLocationChannel - val isTeleported: LiveData = state.isTeleported - val geohashPeople: LiveData> = state.geohashPeople - val teleportedGeo: LiveData> = state.teleportedGeo - val geohashParticipantCounts: LiveData> = state.geohashParticipantCounts + val showCommandSuggestions: StateFlow = state.showCommandSuggestions + val commandSuggestions: StateFlow> = state.commandSuggestions + val showMentionSuggestions: StateFlow = state.showMentionSuggestions + val mentionSuggestions: StateFlow> = state.mentionSuggestions + val favoritePeers: StateFlow> = state.favoritePeers + val peerSessionStates: StateFlow> = state.peerSessionStates + val peerFingerprints: StateFlow> = state.peerFingerprints + val peerNicknames: StateFlow> = state.peerNicknames + val peerRSSI: StateFlow> = state.peerRSSI + val peerDirect: StateFlow> = state.peerDirect + val showAppInfo: StateFlow = state.showAppInfo + val selectedLocationChannel: StateFlow = state.selectedLocationChannel + val isTeleported: StateFlow = state.isTeleported + val geohashPeople: StateFlow> = state.geohashPeople + val teleportedGeo: StateFlow> = state.teleportedGeo + val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts init { // Note: Mesh service delegate is now set by MainActivity @@ -565,7 +567,7 @@ class ChatViewModel( private fun logCurrentFavoriteState() { Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===") - Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}") + Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}") Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}") Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}") Log.i("ChatViewModel", "==============================") diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt index 1b0837a22..0bdc85ee1 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt @@ -9,7 +9,6 @@ import androidx.compose.material.icons.outlined.Explore import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -21,6 +20,7 @@ import com.bitchat.android.ui.theme.BASE_FONT_SIZE import java.util.* import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R /** @@ -46,11 +46,11 @@ fun GeohashPeopleList( val colorScheme = MaterialTheme.colorScheme // Observe geohash people from ChatViewModel - val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList()) - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() - val isTeleported by viewModel.isTeleported.observeAsState(false) - val nickname by viewModel.nickname.observeAsState("") - val unreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) + val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle() + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() + val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() Column { // Header matching iOS style diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index 060bb84e7..52e00edf5 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -3,7 +3,6 @@ package com.bitchat.android.ui import android.app.Application import android.util.Log import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope import com.bitchat.android.nostr.GeohashMessageHandler import com.bitchat.android.nostr.GeohashRepository @@ -15,6 +14,7 @@ import com.bitchat.android.nostr.NostrSubscriptionManager import com.bitchat.android.nostr.PoWPreferenceManager import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.util.Date @@ -55,9 +55,9 @@ class GeohashViewModel( private var geoTimer: Job? = null private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null - val geohashPeople: LiveData> = state.geohashPeople - val geohashParticipantCounts: LiveData> = state.geohashParticipantCounts - val selectedLocationChannel: LiveData = state.selectedLocationChannel + val geohashPeople: StateFlow> = state.geohashPeople + val geohashParticipantCounts: StateFlow> = state.geohashParticipantCounts + val selectedLocationChannel: StateFlow = state.selectedLocationChannel fun initialize() { subscriptionManager.connect() @@ -73,12 +73,16 @@ class GeohashViewModel( } try { locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) - locationChannelManager?.selectedChannel?.observeForever { channel -> - state.setSelectedLocationChannel(channel) - switchLocationChannel(channel) + viewModelScope.launch { + locationChannelManager?.selectedChannel?.collect { channel -> + state.setSelectedLocationChannel(channel) + switchLocationChannel(channel) + } } - locationChannelManager?.teleported?.observeForever { teleported -> - state.setIsTeleported(teleported) + viewModelScope.launch { + locationChannelManager?.teleported?.collect { teleported -> + state.setIsTeleported(teleported) + } } } catch (e: Exception) { Log.e(TAG, "Failed to initialize location channel state: ${e.message}") @@ -120,7 +124,7 @@ class GeohashViewModel( } try { val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication()) - val teleported = state.isTeleported.value ?: false + val teleported = state.isTeleported.value val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported) val relayManager = NostrRelayManager.getInstance(getApplication()) relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5) @@ -231,7 +235,7 @@ class GeohashViewModel( try { val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication()) repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date()) - val teleported = state.isTeleported.value ?: false + val teleported = state.isTeleported.value if (teleported) repo.markTeleported(identity.publicKeyHex) } catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") } 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 251c8af90..6f47d68f7 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -18,7 +18,6 @@ import androidx.compose.material.icons.filled.PinDrop import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged @@ -38,7 +37,9 @@ import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.geohash.GeohashBookmarksStore import com.bitchat.android.ui.theme.BASE_FONT_SIZE import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R +import com.bitchat.android.core.ui.component.button.CloseButton /** * Location Channels Sheet for selecting geohash-based location channels @@ -57,18 +58,18 @@ fun LocationChannelsSheet( val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) } // Observe location manager state - val permissionState by locationManager.permissionState.observeAsState() - val availableChannels by locationManager.availableChannels.observeAsState(emptyList()) - val selectedChannel by locationManager.selectedChannel.observeAsState() - val locationNames by locationManager.locationNames.observeAsState(emptyMap()) - val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false) + val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() + val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() + val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle() + val locationNames by locationManager.locationNames.collectAsStateWithLifecycle() + val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle() // Observe bookmarks state - val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList()) - val bookmarkNames by bookmarksStore.bookmarkNames.observeAsState(emptyMap()) + val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle() + val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle() // Observe reactive participant counts - val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap()) + val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle() // UI state var customGeohash by remember { mutableStateOf("") } @@ -551,18 +552,12 @@ fun LocationChannelsSheet( .height(56.dp) .background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha)) ) { - TextButton( + CloseButton( onClick = onDismiss, - modifier = Modifier + modifier = modifier .align(Alignment.CenterEnd) - .padding(horizontal = 16.dp) - ) { - Text( - text = stringResource(R.string.close_plain), - style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.onBackground - ) - } + .padding(horizontal = 16.dp), + ) } } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 09084a920..601d4c038 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -7,8 +7,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -35,10 +35,10 @@ fun LocationNotesButton( val context = LocalContext.current // Get channel and permission state - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle() val locationManager = remember { LocationChannelManager.getInstance(context) } - val permissionState by locationManager.permissionState.observeAsState() - val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false) + val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() + val locationServicesEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle(false) // Check both permission AND location services enabled val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED @@ -46,7 +46,7 @@ fun LocationNotesButton( // Get notes count from LocationNotesManager val notesManager = remember { LocationNotesManager.getInstance() } - val notes by notesManager.notes.observeAsState(emptyList()) + val notes by notesManager.notes.collectAsStateWithLifecycle() val notesCount = notes.size // Only show in mesh mode when location is authorized (iOS pattern) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index 23b9d952b..461796d3e 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -12,7 +12,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -25,6 +24,7 @@ import com.bitchat.android.R import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.nostr.LocationNotesManager @@ -57,16 +57,16 @@ fun LocationNotesSheet( val locationManager = remember { LocationChannelManager.getInstance(context) } // State - val notes by notesManager.notes.observeAsState(emptyList()) - val state by notesManager.state.observeAsState(LocationNotesManager.State.IDLE) - val errorMessage by notesManager.errorMessage.observeAsState() - val initialLoadComplete by notesManager.initialLoadComplete.observeAsState(false) + val notes by notesManager.notes.collectAsStateWithLifecycle() + val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE) + val errorMessage by notesManager.errorMessage.collectAsStateWithLifecycle() + val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(false) // SIMPLIFIED: Get count directly from notes list (no separate counter needed) val count = notes.size // Get location name (building or block) - matches iOS locationNames lookup - val locationNames by locationManager.locationNames.observeAsState(emptyMap()) + val locationNames by locationManager.locationNames.collectAsStateWithLifecycle() val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() } ?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt index 9d4da4c9e..d17b6d7c4 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt @@ -4,12 +4,12 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager @@ -26,15 +26,15 @@ fun LocationNotesSheetPresenter( ) { val context = LocalContext.current val locationManager = remember { LocationChannelManager.getInstance(context) } - val availableChannels by locationManager.availableChannels.observeAsState(emptyList()) - val nickname by viewModel.nickname.observeAsState("") + val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() // iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash if (buildingGeohash != null) { // Get location name from locationManager - val locationNames by locationManager.locationNames.observeAsState(emptyMap()) + val locationNames by locationManager.locationNames.collectAsStateWithLifecycle() val locationName = locationNames[GeohashChannelLevel.BUILDING] ?: locationNames[GeohashChannelLevel.BLOCK] diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt index 1ea59444c..9c7d776f6 100644 --- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt +++ b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -26,7 +27,7 @@ private enum class CharacterAnimationState { */ @Composable fun shouldAnimateMessage(messageId: String): Boolean { - val miningMessages by PoWMiningTracker.miningMessages.collectAsState() + val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle() return miningMessages.contains(messageId) } diff --git a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt b/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt index b0d551289..883b2b45d 100644 --- a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt +++ b/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.nostr.NostrProofOfWork import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.R import com.bitchat.android.nostr.PoWPreferenceManager @@ -27,9 +28,9 @@ fun PoWStatusIndicator( modifier: Modifier = Modifier, style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT ) { - val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() - val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() - val isMining by PoWPreferenceManager.isMining.collectAsState() + val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle() + val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle() + val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle() val colorScheme = MaterialTheme.colorScheme val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f 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 a8c59ef7e..4ab4360af 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -12,7 +12,6 @@ import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -22,6 +21,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitchat.android.ui.theme.BASE_FONT_SIZE @@ -39,14 +39,14 @@ fun SidebarOverlay( val colorScheme = MaterialTheme.colorScheme val interactionSource = remember { MutableInteractionSource() } - val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList()) - val joinedChannels by viewModel.joinedChannels.observeAsState(emptyList()) - val currentChannel by viewModel.currentChannel.observeAsState() - val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState() - val nickname by viewModel.nickname.observeAsState("") - val unreadChannelMessages by viewModel.unreadChannelMessages.observeAsState(emptyMap()) - val peerNicknames by viewModel.peerNicknames.observeAsState(emptyMap()) - val peerRSSI by viewModel.peerRSSI.observeAsState(emptyMap()) + val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle() + val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle() + val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle() + val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle() + val nickname by viewModel.nickname.collectAsStateWithLifecycle() + val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle() + val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle() + val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle() Box( modifier = modifier @@ -110,7 +110,7 @@ fun SidebarOverlay( // People section - switch between mesh and geohash lists (iOS-compatible) item { - val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() + val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsState() when (selectedLocationChannel) { is com.bitchat.android.geohash.ChannelID.Location -> { @@ -291,10 +291,10 @@ fun PeopleSection( } // Observe reactive state for favorites and fingerprints - val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet()) - val privateChats by viewModel.privateChats.observeAsState(emptyMap()) - val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet()) - val peerFingerprints by viewModel.peerFingerprints.observeAsState(emptyMap()) + val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle() + val privateChats by viewModel.privateChats.collectAsStateWithLifecycle() + val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle() + val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle() // Reactive favorite computation for all peers val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) { @@ -384,7 +384,7 @@ fun PeopleSection( val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName) val showHash = (baseNameCounts[bName] ?: 0) > 1 - val directMap by viewModel.peerDirect.observeAsState(emptyMap()) + val directMap by viewModel.peerDirect.collectAsStateWithLifecycle() val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false } PeerItem( peerID = peerID, diff --git a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt index 6afdf6d60..f1b33d325 100644 --- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt @@ -5,7 +5,9 @@ import androidx.test.core.app.ApplicationProvider import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage import junit.framework.TestCase.assertEquals - +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Before import org.junit.Ignore import org.junit.Test @@ -18,7 +20,10 @@ import java.util.Date @RunWith(RobolectricTestRunner::class) class CommandProcessorTest() { private val context: Context = ApplicationProvider.getApplicationContext() - private val chatState = ChatState() + @OptIn(ExperimentalCoroutinesApi::class) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val chatState = ChatState(scope = testScope) private lateinit var commandProcessor: CommandProcessor val messageManager: MessageManager = MessageManager(state = chatState) @@ -26,7 +31,7 @@ class CommandProcessorTest() { state = chatState, messageManager = messageManager, dataManager = DataManager(context = context), - coroutineScope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main.immediate) + coroutineScope = testScope ) private val meshService: BluetoothMeshService = mock() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6dda224a..8e0790a03 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,12 +68,10 @@ androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-compose-material3 = { module = "androidx.compose.material3:material3" } -androidx-compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata" } androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } # Lifecycle androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle-runtime" } -androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle-runtime" } # Navigation androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } @@ -130,14 +128,12 @@ compose = [ "androidx-compose-ui-graphics", "androidx-compose-ui-tooling-preview", "androidx-compose-material3", - "androidx-compose-runtime-livedata", "androidx-compose-material-icons-extended" ] lifecycle = [ "androidx-lifecycle-runtime-ktx", "androidx-lifecycle-viewmodel-compose", - "androidx-lifecycle-livedata-ktx" ] cryptography = [