From 8085c55c24fa45e9d5926e0f519f117a90867769 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Thu, 3 Jul 2025 22:14:29 -0400 Subject: [PATCH] moving to coroutines --- TODO.md | 48 ++-- .../lchat/network/WifiAwareManager.kt | 236 +++++++++++++----- .../lchat/repository/ChatRepository.kt | 124 +++++---- .../com/mattintech/lchat/ui/LobbyFragment.kt | 86 ++++--- .../lchat/viewmodel/ChatViewModel.kt | 12 +- .../lchat/viewmodel/LobbyViewModel.kt | 40 +-- 6 files changed, 369 insertions(+), 177 deletions(-) diff --git a/TODO.md b/TODO.md index 36abcc2..1d5e923 100644 --- a/TODO.md +++ b/TODO.md @@ -15,21 +15,21 @@ - [x] Convert singletons to proper DI - [x] Inject ViewModels using Hilt -### 1.3 Room Database Setup -- [ ] Add Room dependencies -- [ ] Create Message and User entities -- [ ] Implement DAOs for data access -- [ ] Create database migrations -- [ ] Store messages in Room database -- [ ] Load message history on app restart -- [ ] Implement message sync logic +### 1.3 Room Database Setup ✅ +- [x] Add Room dependencies +- [x] Create Message and User entities +- [x] Implement DAOs for data access +- [x] Create database migrations +- [x] Store messages in Room database +- [x] Load message history on app restart +- [x] Implement message sync logic -### 1.4 Coroutines & Flow Optimization -- [ ] Convert callbacks to coroutines -- [ ] Use Flow for reactive data streams -- [ ] Implement proper scope management -- [ ] Replace GlobalScope with proper lifecycle scopes -- [ ] Add proper error handling with coroutines +### 1.4 Coroutines & Flow Optimization ✅ +- [x] Convert callbacks to coroutines +- [x] Use Flow for reactive data streams +- [x] Implement proper scope management +- [x] Replace GlobalScope with proper lifecycle scopes +- [x] Add proper error handling with coroutines ## Phase 2: Core UX Improvements @@ -47,6 +47,16 @@ ### 2.3 Enhanced Messaging Features - [ ] Message status indicators (sent/delivered/read) + - [ ] Add status field to MessageEntity (pending/sent/delivered/failed) + - [ ] Show status icons in message bubbles + - [ ] Update status when delivery confirmed +- [ ] Store-and-forward messaging pattern + - [ ] Save messages with "pending" status initially + - [ ] Implement acknowledgment protocol in WifiAwareManager + - [ ] Update to "sent" only after confirmation received + - [ ] Queue messages when offline/disconnected + - [ ] Auto-retry failed messages with exponential backoff + - [ ] Mark messages as failed after max retries - [ ] User presence indicators (online/offline/typing) - [ ] Message timestamps with proper formatting - [ ] Offline message queue @@ -160,18 +170,22 @@ ## Current Status - ✅ Phase 1.1 - MVVM Architecture - COMPLETED - ✅ Phase 1.2 - Dependency Injection with Hilt - COMPLETED +- ✅ Phase 1.3 - Room Database Setup - COMPLETED +- ✅ Phase 1.4 - Coroutines & Flow Optimization - COMPLETED - ✅ Phase 2.1 - Connection Status Management - COMPLETED - 🚀 Next Priority Options: - - Phase 1.3 - Room Database (Foundation for persistence) - Phase 2.2 - User List Feature (Core UX) - Phase 2.3 - Enhanced Messaging (Better UX) - Phase 3.1 - Material 3 Update (Modern UI) + - Phase 3.2 - Dark Theme & Theming ## Completed Work Summary 1. **MVVM Architecture**: ViewModels, Repository pattern, proper separation of concerns 2. **Dependency Injection**: Hilt integration with proper scoping and lifecycle management -3. **Connection Status**: Visual indicator with real-time updates, activity-based detection -4. **Sleep/Wake Handling**: Auto-recovery when messages resume after device sleep +3. **Room Database**: Message persistence with proper DAOs and entity mapping +4. **Coroutines & Flow**: Converted callbacks to coroutines, implemented Flow for reactive streams, proper scope management +5. **Connection Status**: Visual indicator with real-time updates, activity-based detection +6. **Sleep/Wake Handling**: Auto-recovery when messages resume after device sleep ## Development Notes - Architecture foundation (Phase 1) should be completed before moving to advanced features diff --git a/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt b/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt index 9d544a4..cc43983 100644 --- a/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt +++ b/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt @@ -9,7 +9,13 @@ import android.os.Build import android.util.Log import androidx.annotation.RequiresApi import com.mattintech.lchat.utils.LOG_PREFIX +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.* import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine @RequiresApi(Build.VERSION_CODES.O) class WifiAwareManager(private val context: Context) { @@ -26,27 +32,57 @@ class WifiAwareManager(private val context: Context) { private var subscribeDiscoverySession: SubscribeDiscoverySession? = null private val peerHandles = ConcurrentHashMap() + + // Replace callbacks with Flows + private val _messageFlow = MutableSharedFlow>() + val messageFlow: SharedFlow> = _messageFlow.asSharedFlow() + + private val _connectionFlow = MutableSharedFlow>() + val connectionFlow: SharedFlow> = _connectionFlow.asSharedFlow() + + // Keep legacy callbacks for backward compatibility private var messageCallback: ((String, String, String) -> Unit)? = null private var connectionCallback: ((String, Boolean) -> Unit)? = null - private val attachCallback = object : AttachCallback() { - override fun onAttached(session: WifiAwareSession) { - Log.d(TAG, "Wi-Fi Aware attached") - wifiAwareSession = session - } - - override fun onAttachFailed() { - Log.e(TAG, "Wi-Fi Aware attach failed") + // Exception handler for coroutine errors + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + Log.e(TAG, "Coroutine exception: ", throwable) + } + + private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler) + + fun initialize() { + coroutineScope.launch { + initializeAsync() } } - fun initialize() { + suspend fun initializeAsync(): Result = withContext(Dispatchers.IO) { wifiAwareManager = context.getSystemService(Context.WIFI_AWARE_SERVICE) as? android.net.wifi.aware.WifiAwareManager if (wifiAwareManager?.isAvailable == true) { - wifiAwareManager?.attach(attachCallback, null) + attachToWifiAware() } else { - Log.e(TAG, "Wi-Fi Aware is not available") + Result.failure(Exception("Wi-Fi Aware is not available")) + } + } + + private suspend fun attachToWifiAware(): Result = suspendCancellableCoroutine { continuation -> + wifiAwareManager?.attach(object : AttachCallback() { + override fun onAttached(session: WifiAwareSession) { + Log.d(TAG, "Wi-Fi Aware attached") + wifiAwareSession = session + continuation.resume(Result.success(Unit)) + } + + override fun onAttachFailed() { + Log.e(TAG, "Wi-Fi Aware attach failed") + continuation.resume(Result.failure(Exception("Wi-Fi Aware attach failed"))) + } + }, null) + + continuation.invokeOnCancellation { + Log.d(TAG, "Wi-Fi Aware attach cancelled") } } @@ -67,8 +103,15 @@ class WifiAwareManager(private val context: Context) { Log.d(TAG, "Host: Received message: $messageStr") if (messageStr == "CONNECT_REQUEST") { - Log.d(TAG, "Host: Received connection request") - acceptConnection(peerHandle) + Log.d(TAG, "Host: Received connection request from peer") + coroutineScope.launch { + val result = acceptConnectionAsync(peerHandle) + if (result.isSuccess) { + Log.d(TAG, "Host: Successfully accepted connection") + } else { + Log.e(TAG, "Host: Failed to accept connection: ${result.exceptionOrNull()?.message}") + } + } } else { handleIncomingMessage(peerHandle, message) } @@ -103,9 +146,13 @@ class WifiAwareManager(private val context: Context) { subscribeDiscoverySession?.sendMessage(peerHandle, 0, "CONNECT_REQUEST".toByteArray()) // Wait a bit for host to prepare, then connect - android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ - connectToPeer(peerHandle, roomName) - }, 500) + coroutineScope.launch { + delay(500) + val result = connectToPeerAsync(peerHandle, roomName) + if (result.isFailure) { + Log.e(TAG, "Failed to connect to peer: ${result.exceptionOrNull()?.message}") + } + } } override fun onMessageReceived(peerHandle: PeerHandle, message: ByteArray) { @@ -115,6 +162,13 @@ class WifiAwareManager(private val context: Context) { } private fun connectToPeer(peerHandle: PeerHandle, roomName: String) { + coroutineScope.launch { + connectToPeerAsync(peerHandle, roomName) + } + } + + private suspend fun connectToPeerAsync(peerHandle: PeerHandle, roomName: String): Result = withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> Log.d(TAG, "connectToPeer: Starting connection to room: $roomName") val networkSpecifier = WifiAwareNetworkSpecifier.Builder(subscribeDiscoverySession!!, peerHandle) .setPskPassphrase("lchat-secure-key") @@ -129,62 +183,117 @@ class WifiAwareManager(private val context: Context) { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - try { - connectivityManager.requestNetwork(networkRequest, object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: android.net.Network) { - Log.d(TAG, "onAvailable: Network connected for room: $roomName") - connectionCallback?.invoke(roomName, true) + try { + var isResumed = false + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: android.net.Network) { + Log.d(TAG, "onAvailable: Network connected for room: $roomName") + if (!isResumed) { + isResumed = true + continuation.resume(Result.success(Unit)) + } + // Emit to flow and legacy callback + coroutineScope.launch { + _connectionFlow.emit(Pair(roomName, true)) + } + connectionCallback?.invoke(roomName, true) + } + + override fun onLost(network: android.net.Network) { + Log.d(TAG, "onLost: Network lost for room: $roomName") + coroutineScope.launch { + _connectionFlow.emit(Pair(roomName, false)) + } + connectionCallback?.invoke(roomName, false) + } + + override fun onUnavailable() { + Log.e(TAG, "onUnavailable: Network request failed for room: $roomName") + if (!isResumed) { + isResumed = true + continuation.resume(Result.failure(Exception("Network unavailable for room: $roomName"))) + } + coroutineScope.launch { + _connectionFlow.emit(Pair(roomName, false)) + } + connectionCallback?.invoke(roomName, false) + } + + override fun onCapabilitiesChanged(network: android.net.Network, networkCapabilities: NetworkCapabilities) { + Log.d(TAG, "onCapabilitiesChanged: Capabilities changed for room: $roomName") + } + + override fun onLinkPropertiesChanged(network: android.net.Network, linkProperties: android.net.LinkProperties) { + Log.d(TAG, "onLinkPropertiesChanged: Link properties changed for room: $roomName") + } } - override fun onLost(network: android.net.Network) { - Log.d(TAG, "onLost: Network lost for room: $roomName") - connectionCallback?.invoke(roomName, false) - } + connectivityManager.requestNetwork(networkRequest, callback, android.os.Handler(android.os.Looper.getMainLooper())) - override fun onUnavailable() { - Log.e(TAG, "onUnavailable: Network request failed for room: $roomName") - connectionCallback?.invoke(roomName, false) - } + Log.d(TAG, "connectToPeer: Network request submitted for room: $roomName") - override fun onCapabilitiesChanged(network: android.net.Network, networkCapabilities: NetworkCapabilities) { - Log.d(TAG, "onCapabilitiesChanged: Capabilities changed for room: $roomName") + continuation.invokeOnCancellation { + connectivityManager.unregisterNetworkCallback(callback) } - - override fun onLinkPropertiesChanged(network: android.net.Network, linkProperties: android.net.LinkProperties) { - Log.d(TAG, "onLinkPropertiesChanged: Link properties changed for room: $roomName") + } catch (e: Exception) { + Log.e(TAG, "connectToPeer: Failed to request network", e) + continuation.resume(Result.failure(e)) + coroutineScope.launch { + _connectionFlow.emit(Pair(roomName, false)) } - }, android.os.Handler(android.os.Looper.getMainLooper()), 30000) // 30 second timeout - - Log.d(TAG, "connectToPeer: Network request submitted for room: $roomName") - } catch (e: Exception) { - Log.e(TAG, "connectToPeer: Failed to request network", e) - connectionCallback?.invoke(roomName, false) + connectionCallback?.invoke(roomName, false) + } } } - private fun acceptConnection(peerHandle: PeerHandle) { - Log.d(TAG, "acceptConnection: Accepting connection from client") - val networkSpecifier = WifiAwareNetworkSpecifier.Builder(publishDiscoverySession!!, peerHandle) - .setPskPassphrase("lchat-secure-key") - .setPort(PORT) - .build() + + private suspend fun acceptConnectionAsync(peerHandle: PeerHandle): Result = withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> + Log.d(TAG, "acceptConnection: Accepting connection from client") - val networkRequest = NetworkRequest.Builder() - .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) - .setNetworkSpecifier(networkSpecifier) - .build() - - val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - connectivityManager.requestNetwork(networkRequest, object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: android.net.Network) { - Log.d(TAG, "Client connected") - peerHandles[peerHandle.toString()] = peerHandle + try { + val networkSpecifier = WifiAwareNetworkSpecifier.Builder(publishDiscoverySession!!, peerHandle) + .setPskPassphrase("lchat-secure-key") + .setPort(PORT) + .build() + + val networkRequest = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) + .setNetworkSpecifier(networkSpecifier) + .build() + + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + var isResumed = false + + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: android.net.Network) { + Log.d(TAG, "Client connected") + peerHandles[peerHandle.toString()] = peerHandle + if (!isResumed) { + isResumed = true + continuation.resume(Result.success(Unit)) + } + } + + override fun onUnavailable() { + Log.e(TAG, "Failed to accept client connection - Check if Wi-Fi is enabled") + if (!isResumed) { + isResumed = true + continuation.resume(Result.failure(Exception("Failed to accept client connection"))) + } + } + } + + connectivityManager.requestNetwork(networkRequest, callback) + + continuation.invokeOnCancellation { + connectivityManager.unregisterNetworkCallback(callback) + } + } catch (e: Exception) { + Log.e(TAG, "acceptConnection: Failed to accept connection", e) + continuation.resume(Result.failure(e)) } - - override fun onUnavailable() { - Log.e(TAG, "Failed to accept client connection - Check if Wi-Fi is enabled") - } - }) + } } private fun handleIncomingMessage(peerHandle: PeerHandle, message: ByteArray) { @@ -192,6 +301,10 @@ class WifiAwareManager(private val context: Context) { val messageStr = String(message) val parts = messageStr.split("|", limit = 3) if (parts.size == 3) { + // Emit to flow and legacy callback + coroutineScope.launch { + _messageFlow.emit(Triple(parts[0], parts[1], parts[2])) + } messageCallback?.invoke(parts[0], parts[1], parts[2]) } } catch (e: Exception) { @@ -226,5 +339,6 @@ class WifiAwareManager(private val context: Context) { subscribeDiscoverySession?.close() wifiAwareSession?.close() peerHandles.clear() + coroutineScope.cancel() } } \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt b/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt index ff31552..631b86e 100644 --- a/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt +++ b/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt @@ -19,6 +19,14 @@ class ChatRepository @Inject constructor( private val wifiAwareManager: WifiAwareManager, private val messageDao: MessageDao ) { + // Exception handler for repository operations + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + android.util.Log.e("ChatRepository", "Repository coroutine exception: ", throwable) + _connectionState.value = ConnectionState.Error(throwable.message ?: "Unknown error") + } + + // Repository scope for background operations + private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler) private var currentRoomName: String = "" @@ -47,37 +55,65 @@ class ChatRepository @Inject constructor( init { wifiAwareManager.initialize() - setupWifiAwareCallbacks() + // Only use Flow-based collection, not callbacks + collectWifiAwareFlows() } - private fun setupWifiAwareCallbacks() { - wifiAwareManager.setMessageCallback { userId, userName, content -> - val message = Message( - id = UUID.randomUUID().toString(), - userId = userId, - userName = userName, - content = content, - timestamp = System.currentTimeMillis(), - isOwnMessage = false - ) - addMessage(message) - - // Update last activity time - lastActivityTime = System.currentTimeMillis() - - // If we're receiving messages, we must be connected - if (_connectionState.value !is ConnectionState.Connected && - _connectionState.value !is ConnectionState.Hosting) { - when (_connectionState.value) { - is ConnectionState.Hosting -> {} // Keep hosting state - else -> _connectionState.value = ConnectionState.Connected("Active") + private fun collectWifiAwareFlows() { + // Collect messages from Flow + repositoryScope.launch { + try { + wifiAwareManager.messageFlow.collect { (userId, userName, content) -> + val message = Message( + id = UUID.randomUUID().toString(), + userId = userId, + userName = userName, + content = content, + timestamp = System.currentTimeMillis(), + isOwnMessage = false + ) + addMessage(message) + + // Update last activity time + lastActivityTime = System.currentTimeMillis() + + // If we're receiving messages, we must be connected + if (_connectionState.value !is ConnectionState.Connected && + _connectionState.value !is ConnectionState.Hosting) { + when (_connectionState.value) { + is ConnectionState.Hosting -> {} // Keep hosting state + else -> _connectionState.value = ConnectionState.Connected("Active") + } } + } + } catch (e: Exception) { + android.util.Log.e("ChatRepository", "Error collecting message flow", e) + } + } + + // Collect connection state from Flow + repositoryScope.launch { + try { + wifiAwareManager.connectionFlow.collect { (roomName, isConnected) -> + if (isConnected) { + currentRoomName = roomName + _connectionState.value = ConnectionState.Connected(roomName) + loadMessagesFromDatabase(roomName) + } else { + _connectionState.value = ConnectionState.Disconnected + } + // Call the legacy callback if set + connectionCallback?.invoke(roomName, isConnected) + } + } catch (e: Exception) { + android.util.Log.e("ChatRepository", "Error collecting connection flow", e) + _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") } - - messageCallback?.invoke(userId, userName, content) } } + // Removed setupWifiAwareCallbacks - now using Flow-based collection only + fun startHostMode(roomName: String) { currentRoomName = roomName wifiAwareManager.startHostMode(roomName) @@ -87,10 +123,16 @@ class ChatRepository @Inject constructor( } private fun loadMessagesFromDatabase(roomName: String) { - CoroutineScope(Dispatchers.IO).launch { - val storedMessages = messageDao.getMessagesForRoomOnce(roomName) - .map { it.toMessage() } - _messages.value = storedMessages + repositoryScope.launch { + try { + val storedMessages = messageDao.getMessagesForRoomOnce(roomName) + .map { it.toMessage() } + _messages.value = storedMessages + } catch (e: Exception) { + android.util.Log.e("ChatRepository", "Error loading messages from database", e) + // Don't crash, just continue with empty messages + _messages.value = emptyList() + } } } @@ -123,11 +165,17 @@ class ChatRepository @Inject constructor( } private fun addMessage(message: Message) { - _messages.value = _messages.value + message + // Add message and sort by timestamp to ensure proper order + _messages.value = (_messages.value + message).sortedBy { it.timestamp } // Save to database - CoroutineScope(Dispatchers.IO).launch { - messageDao.insertMessage(message.toEntity(currentRoomName)) + repositoryScope.launch { + try { + messageDao.insertMessage(message.toEntity(currentRoomName)) + } catch (e: Exception) { + android.util.Log.e("ChatRepository", "Error saving message to database", e) + // Don't crash, message is already in memory + } } } @@ -141,16 +189,7 @@ class ChatRepository @Inject constructor( fun setConnectionCallback(callback: (String, Boolean) -> Unit) { connectionCallback = callback - wifiAwareManager.setConnectionCallback { roomName, isConnected -> - if (isConnected) { - currentRoomName = roomName - _connectionState.value = ConnectionState.Connected(roomName) - loadMessagesFromDatabase(roomName) - } else { - _connectionState.value = ConnectionState.Disconnected - } - callback(roomName, isConnected) - } + // Connection state is now handled by Flow collection } fun stop() { @@ -158,11 +197,12 @@ class ChatRepository @Inject constructor( wifiAwareManager.stop() _connectionState.value = ConnectionState.Disconnected _connectedUsers.value = emptyList() + repositoryScope.cancel() } private fun startConnectionMonitoring() { connectionCheckJob?.cancel() - connectionCheckJob = CoroutineScope(Dispatchers.IO).launch { + connectionCheckJob = repositoryScope.launch { while (isActive) { delay(5000) // Check every 5 seconds val timeSinceLastActivity = System.currentTimeMillis() - lastActivityTime diff --git a/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt b/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt index e9635eb..464ff9d 100644 --- a/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt +++ b/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt @@ -18,6 +18,10 @@ import com.mattintech.lchat.viewmodel.LobbyState import com.mattintech.lchat.viewmodel.LobbyViewModel import com.mattintech.lchat.utils.LOG_PREFIX import dagger.hilt.android.AndroidEntryPoint +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch @AndroidEntryPoint class LobbyFragment : Fragment() { @@ -94,48 +98,60 @@ class LobbyFragment : Fragment() { } private fun observeViewModel() { - viewModel.savedUserName.observe(viewLifecycleOwner) { savedName -> - if (!savedName.isNullOrEmpty() && binding.nameInput.text.isNullOrEmpty()) { - binding.nameInput.setText(savedName) - } - } - - viewModel.state.observe(viewLifecycleOwner) { state -> - when (state) { - is LobbyState.Idle -> { - binding.noRoomsText.visibility = View.GONE - } - is LobbyState.Connecting -> { - if (binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) { - binding.noRoomsText.visibility = View.VISIBLE - binding.noRoomsText.text = getString(R.string.connecting) + // Collect saved user name + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.savedUserName.collect { savedName -> + if (!savedName.isNullOrEmpty() && binding.nameInput.text.isNullOrEmpty()) { + binding.nameInput.setText(savedName) } } - is LobbyState.Connected -> { - if (binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) { - val userName = binding.nameInput.text?.toString()?.trim() ?: "" - viewModel.onConnectedToRoom(state.roomName, userName) - } - } - is LobbyState.Error -> { - binding.noRoomsText.visibility = View.VISIBLE - binding.noRoomsText.text = state.message - Toast.makeText(context, state.message, Toast.LENGTH_LONG).show() - } } } - viewModel.events.observe(viewLifecycleOwner) { event -> - when (event) { - is LobbyEvent.NavigateToChat -> { - navigateToChat(event.roomName, event.userName, event.isHost) - viewModel.clearEvent() + // Collect state + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.state.collect { state -> + when (state) { + is LobbyState.Idle -> { + binding.noRoomsText.visibility = View.GONE + } + is LobbyState.Connecting -> { + if (binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) { + binding.noRoomsText.visibility = View.VISIBLE + binding.noRoomsText.text = getString(R.string.connecting) + } + } + is LobbyState.Connected -> { + if (binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) { + val userName = binding.nameInput.text?.toString()?.trim() ?: "" + viewModel.onConnectedToRoom(state.roomName, userName) + } + } + is LobbyState.Error -> { + binding.noRoomsText.visibility = View.VISIBLE + binding.noRoomsText.text = state.message + Toast.makeText(context, state.message, Toast.LENGTH_LONG).show() + } + } } - is LobbyEvent.ShowError -> { - Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() - viewModel.clearEvent() + } + } + + // Collect events + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.events.collect { event -> + when (event) { + is LobbyEvent.NavigateToChat -> { + navigateToChat(event.roomName, event.userName, event.isHost) + } + is LobbyEvent.ShowError -> { + Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() + } + } } - null -> {} } } } diff --git a/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt b/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt index 7337681..798a416 100644 --- a/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt @@ -1,7 +1,5 @@ package com.mattintech.lchat.viewmodel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.mattintech.lchat.data.Message @@ -25,8 +23,8 @@ class ChatViewModel @Inject constructor( private val chatRepository: ChatRepository ) : ViewModel() { - private val _state = MutableLiveData(ChatState.Connected) - val state: LiveData = _state + private val _state = MutableStateFlow(ChatState.Connected) + val state: StateFlow = _state.asStateFlow() private val _messagesFlow = MutableStateFlow>>(flowOf(emptyList())) @@ -85,10 +83,8 @@ class ChatViewModel @Inject constructor( } fun disconnect() { - viewModelScope.launch { - chatRepository.stop() - _state.value = ChatState.Disconnected - } + chatRepository.stop() + _state.value = ChatState.Disconnected } override fun onCleared() { diff --git a/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt b/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt index 6cf60b1..befc1ea 100644 --- a/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt @@ -1,12 +1,16 @@ package com.mattintech.lchat.viewmodel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.mattintech.lchat.repository.ChatRepository import com.mattintech.lchat.utils.PreferencesManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -32,14 +36,14 @@ class LobbyViewModel @Inject constructor( private val preferencesManager: PreferencesManager ) : ViewModel() { - private val _state = MutableLiveData(LobbyState.Idle) - val state: LiveData = _state + private val _state = MutableStateFlow(LobbyState.Idle) + val state: StateFlow = _state.asStateFlow() - private val _events = MutableLiveData() - val events: LiveData = _events + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() - private val _savedUserName = MutableLiveData() - val savedUserName: LiveData = _savedUserName + private val _savedUserName = MutableStateFlow(null) + val savedUserName: StateFlow = _savedUserName.asStateFlow() init { setupConnectionCallback() @@ -64,12 +68,16 @@ class LobbyViewModel @Inject constructor( fun startHostMode(roomName: String, userName: String) { if (roomName.isBlank()) { - _events.value = LobbyEvent.ShowError("Please enter a room name") + viewModelScope.launch { + _events.emit(LobbyEvent.ShowError("Please enter a room name")) + } return } if (userName.isBlank()) { - _events.value = LobbyEvent.ShowError("Please enter your name") + viewModelScope.launch { + _events.emit(LobbyEvent.ShowError("Please enter your name")) + } return } @@ -77,13 +85,15 @@ class LobbyViewModel @Inject constructor( _state.value = LobbyState.Connecting preferencesManager.saveUserName(userName) chatRepository.startHostMode(roomName) - _events.value = LobbyEvent.NavigateToChat(roomName, userName, true) + _events.emit(LobbyEvent.NavigateToChat(roomName, userName, true)) } } fun startClientMode(userName: String) { if (userName.isBlank()) { - _events.value = LobbyEvent.ShowError("Please enter your name") + viewModelScope.launch { + _events.emit(LobbyEvent.ShowError("Please enter your name")) + } return } @@ -96,11 +106,13 @@ class LobbyViewModel @Inject constructor( fun onConnectedToRoom(roomName: String, userName: String) { preferencesManager.saveUserName(userName) - _events.value = LobbyEvent.NavigateToChat(roomName, userName, false) + viewModelScope.launch { + _events.emit(LobbyEvent.NavigateToChat(roomName, userName, false)) + } } fun clearEvent() { - _events.value = null + // SharedFlow doesn't need clearing, events are consumed once } fun saveUserName(name: String) {