moving to coroutines

This commit is contained in:
2025-07-03 22:14:29 -04:00
parent 62f6128a2e
commit 8085c55c24
6 changed files with 369 additions and 177 deletions

48
TODO.md
View File

@@ -15,21 +15,21 @@
- [x] Convert singletons to proper DI - [x] Convert singletons to proper DI
- [x] Inject ViewModels using Hilt - [x] Inject ViewModels using Hilt
### 1.3 Room Database Setup ### 1.3 Room Database Setup
- [ ] Add Room dependencies - [x] Add Room dependencies
- [ ] Create Message and User entities - [x] Create Message and User entities
- [ ] Implement DAOs for data access - [x] Implement DAOs for data access
- [ ] Create database migrations - [x] Create database migrations
- [ ] Store messages in Room database - [x] Store messages in Room database
- [ ] Load message history on app restart - [x] Load message history on app restart
- [ ] Implement message sync logic - [x] Implement message sync logic
### 1.4 Coroutines & Flow Optimization ### 1.4 Coroutines & Flow Optimization
- [ ] Convert callbacks to coroutines - [x] Convert callbacks to coroutines
- [ ] Use Flow for reactive data streams - [x] Use Flow for reactive data streams
- [ ] Implement proper scope management - [x] Implement proper scope management
- [ ] Replace GlobalScope with proper lifecycle scopes - [x] Replace GlobalScope with proper lifecycle scopes
- [ ] Add proper error handling with coroutines - [x] Add proper error handling with coroutines
## Phase 2: Core UX Improvements ## Phase 2: Core UX Improvements
@@ -47,6 +47,16 @@
### 2.3 Enhanced Messaging Features ### 2.3 Enhanced Messaging Features
- [ ] Message status indicators (sent/delivered/read) - [ ] 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) - [ ] User presence indicators (online/offline/typing)
- [ ] Message timestamps with proper formatting - [ ] Message timestamps with proper formatting
- [ ] Offline message queue - [ ] Offline message queue
@@ -160,18 +170,22 @@
## Current Status ## Current Status
- ✅ Phase 1.1 - MVVM Architecture - COMPLETED - ✅ Phase 1.1 - MVVM Architecture - COMPLETED
- ✅ Phase 1.2 - Dependency Injection with Hilt - 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 - ✅ Phase 2.1 - Connection Status Management - COMPLETED
- 🚀 Next Priority Options: - 🚀 Next Priority Options:
- Phase 1.3 - Room Database (Foundation for persistence)
- Phase 2.2 - User List Feature (Core UX) - Phase 2.2 - User List Feature (Core UX)
- Phase 2.3 - Enhanced Messaging (Better UX) - Phase 2.3 - Enhanced Messaging (Better UX)
- Phase 3.1 - Material 3 Update (Modern UI) - Phase 3.1 - Material 3 Update (Modern UI)
- Phase 3.2 - Dark Theme & Theming
## Completed Work Summary ## Completed Work Summary
1. **MVVM Architecture**: ViewModels, Repository pattern, proper separation of concerns 1. **MVVM Architecture**: ViewModels, Repository pattern, proper separation of concerns
2. **Dependency Injection**: Hilt integration with proper scoping and lifecycle management 2. **Dependency Injection**: Hilt integration with proper scoping and lifecycle management
3. **Connection Status**: Visual indicator with real-time updates, activity-based detection 3. **Room Database**: Message persistence with proper DAOs and entity mapping
4. **Sleep/Wake Handling**: Auto-recovery when messages resume after device sleep 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 ## Development Notes
- Architecture foundation (Phase 1) should be completed before moving to advanced features - Architecture foundation (Phase 1) should be completed before moving to advanced features

View File

@@ -9,7 +9,13 @@ import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import com.mattintech.lchat.utils.LOG_PREFIX 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 java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
class WifiAwareManager(private val context: Context) { class WifiAwareManager(private val context: Context) {
@@ -26,27 +32,57 @@ class WifiAwareManager(private val context: Context) {
private var subscribeDiscoverySession: SubscribeDiscoverySession? = null private var subscribeDiscoverySession: SubscribeDiscoverySession? = null
private val peerHandles = ConcurrentHashMap<String, PeerHandle>() private val peerHandles = ConcurrentHashMap<String, PeerHandle>()
// Replace callbacks with Flows
private val _messageFlow = MutableSharedFlow<Triple<String, String, String>>()
val messageFlow: SharedFlow<Triple<String, String, String>> = _messageFlow.asSharedFlow()
private val _connectionFlow = MutableSharedFlow<Pair<String, Boolean>>()
val connectionFlow: SharedFlow<Pair<String, Boolean>> = _connectionFlow.asSharedFlow()
// Keep legacy callbacks for backward compatibility
private var messageCallback: ((String, String, String) -> Unit)? = null private var messageCallback: ((String, String, String) -> Unit)? = null
private var connectionCallback: ((String, Boolean) -> Unit)? = null private var connectionCallback: ((String, Boolean) -> Unit)? = null
private val attachCallback = object : AttachCallback() { // 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()
}
}
suspend fun initializeAsync(): Result<Unit> = withContext(Dispatchers.IO) {
wifiAwareManager = context.getSystemService(Context.WIFI_AWARE_SERVICE) as? android.net.wifi.aware.WifiAwareManager
if (wifiAwareManager?.isAvailable == true) {
attachToWifiAware()
} else {
Result.failure(Exception("Wi-Fi Aware is not available"))
}
}
private suspend fun attachToWifiAware(): Result<Unit> = suspendCancellableCoroutine { continuation ->
wifiAwareManager?.attach(object : AttachCallback() {
override fun onAttached(session: WifiAwareSession) { override fun onAttached(session: WifiAwareSession) {
Log.d(TAG, "Wi-Fi Aware attached") Log.d(TAG, "Wi-Fi Aware attached")
wifiAwareSession = session wifiAwareSession = session
continuation.resume(Result.success(Unit))
} }
override fun onAttachFailed() { override fun onAttachFailed() {
Log.e(TAG, "Wi-Fi Aware attach failed") Log.e(TAG, "Wi-Fi Aware attach failed")
continuation.resume(Result.failure(Exception("Wi-Fi Aware attach failed")))
} }
} }, null)
fun initialize() { continuation.invokeOnCancellation {
wifiAwareManager = context.getSystemService(Context.WIFI_AWARE_SERVICE) as? android.net.wifi.aware.WifiAwareManager Log.d(TAG, "Wi-Fi Aware attach cancelled")
if (wifiAwareManager?.isAvailable == true) {
wifiAwareManager?.attach(attachCallback, null)
} else {
Log.e(TAG, "Wi-Fi Aware is not available")
} }
} }
@@ -67,8 +103,15 @@ class WifiAwareManager(private val context: Context) {
Log.d(TAG, "Host: Received message: $messageStr") Log.d(TAG, "Host: Received message: $messageStr")
if (messageStr == "CONNECT_REQUEST") { if (messageStr == "CONNECT_REQUEST") {
Log.d(TAG, "Host: Received connection request") Log.d(TAG, "Host: Received connection request from peer")
acceptConnection(peerHandle) 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 { } else {
handleIncomingMessage(peerHandle, message) handleIncomingMessage(peerHandle, message)
} }
@@ -103,9 +146,13 @@ class WifiAwareManager(private val context: Context) {
subscribeDiscoverySession?.sendMessage(peerHandle, 0, "CONNECT_REQUEST".toByteArray()) subscribeDiscoverySession?.sendMessage(peerHandle, 0, "CONNECT_REQUEST".toByteArray())
// Wait a bit for host to prepare, then connect // Wait a bit for host to prepare, then connect
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ coroutineScope.launch {
connectToPeer(peerHandle, roomName) delay(500)
}, 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) { override fun onMessageReceived(peerHandle: PeerHandle, message: ByteArray) {
@@ -115,6 +162,13 @@ class WifiAwareManager(private val context: Context) {
} }
private fun connectToPeer(peerHandle: PeerHandle, roomName: String) { private fun connectToPeer(peerHandle: PeerHandle, roomName: String) {
coroutineScope.launch {
connectToPeerAsync(peerHandle, roomName)
}
}
private suspend fun connectToPeerAsync(peerHandle: PeerHandle, roomName: String): Result<Unit> = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
Log.d(TAG, "connectToPeer: Starting connection to room: $roomName") Log.d(TAG, "connectToPeer: Starting connection to room: $roomName")
val networkSpecifier = WifiAwareNetworkSpecifier.Builder(subscribeDiscoverySession!!, peerHandle) val networkSpecifier = WifiAwareNetworkSpecifier.Builder(subscribeDiscoverySession!!, peerHandle)
.setPskPassphrase("lchat-secure-key") .setPskPassphrase("lchat-secure-key")
@@ -130,19 +184,38 @@ class WifiAwareManager(private val context: Context) {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
try { try {
connectivityManager.requestNetwork(networkRequest, object : ConnectivityManager.NetworkCallback() { var isResumed = false
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: android.net.Network) { override fun onAvailable(network: android.net.Network) {
Log.d(TAG, "onAvailable: Network connected for room: $roomName") 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) connectionCallback?.invoke(roomName, true)
} }
override fun onLost(network: android.net.Network) { override fun onLost(network: android.net.Network) {
Log.d(TAG, "onLost: Network lost for room: $roomName") Log.d(TAG, "onLost: Network lost for room: $roomName")
coroutineScope.launch {
_connectionFlow.emit(Pair(roomName, false))
}
connectionCallback?.invoke(roomName, false) connectionCallback?.invoke(roomName, false)
} }
override fun onUnavailable() { override fun onUnavailable() {
Log.e(TAG, "onUnavailable: Network request failed for room: $roomName") 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) connectionCallback?.invoke(roomName, false)
} }
@@ -153,17 +226,32 @@ class WifiAwareManager(private val context: Context) {
override fun onLinkPropertiesChanged(network: android.net.Network, linkProperties: android.net.LinkProperties) { override fun onLinkPropertiesChanged(network: android.net.Network, linkProperties: android.net.LinkProperties) {
Log.d(TAG, "onLinkPropertiesChanged: Link properties changed for room: $roomName") Log.d(TAG, "onLinkPropertiesChanged: Link properties changed for room: $roomName")
} }
}, android.os.Handler(android.os.Looper.getMainLooper()), 30000) // 30 second timeout }
connectivityManager.requestNetwork(networkRequest, callback, android.os.Handler(android.os.Looper.getMainLooper()))
Log.d(TAG, "connectToPeer: Network request submitted for room: $roomName") Log.d(TAG, "connectToPeer: Network request submitted for room: $roomName")
continuation.invokeOnCancellation {
connectivityManager.unregisterNetworkCallback(callback)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "connectToPeer: Failed to request network", e) Log.e(TAG, "connectToPeer: Failed to request network", e)
continuation.resume(Result.failure(e))
coroutineScope.launch {
_connectionFlow.emit(Pair(roomName, false))
}
connectionCallback?.invoke(roomName, false) connectionCallback?.invoke(roomName, false)
} }
} }
}
private fun acceptConnection(peerHandle: PeerHandle) {
private suspend fun acceptConnectionAsync(peerHandle: PeerHandle): Result<Unit> = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
Log.d(TAG, "acceptConnection: Accepting connection from client") Log.d(TAG, "acceptConnection: Accepting connection from client")
try {
val networkSpecifier = WifiAwareNetworkSpecifier.Builder(publishDiscoverySession!!, peerHandle) val networkSpecifier = WifiAwareNetworkSpecifier.Builder(publishDiscoverySession!!, peerHandle)
.setPskPassphrase("lchat-secure-key") .setPskPassphrase("lchat-secure-key")
.setPort(PORT) .setPort(PORT)
@@ -175,16 +263,37 @@ class WifiAwareManager(private val context: Context) {
.build() .build()
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivityManager.requestNetwork(networkRequest, object : ConnectivityManager.NetworkCallback() { var isResumed = false
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: android.net.Network) { override fun onAvailable(network: android.net.Network) {
Log.d(TAG, "Client connected") Log.d(TAG, "Client connected")
peerHandles[peerHandle.toString()] = peerHandle peerHandles[peerHandle.toString()] = peerHandle
if (!isResumed) {
isResumed = true
continuation.resume(Result.success(Unit))
}
} }
override fun onUnavailable() { override fun onUnavailable() {
Log.e(TAG, "Failed to accept client connection - Check if Wi-Fi is enabled") 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))
}
} }
})
} }
private fun handleIncomingMessage(peerHandle: PeerHandle, message: ByteArray) { private fun handleIncomingMessage(peerHandle: PeerHandle, message: ByteArray) {
@@ -192,6 +301,10 @@ class WifiAwareManager(private val context: Context) {
val messageStr = String(message) val messageStr = String(message)
val parts = messageStr.split("|", limit = 3) val parts = messageStr.split("|", limit = 3)
if (parts.size == 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]) messageCallback?.invoke(parts[0], parts[1], parts[2])
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -226,5 +339,6 @@ class WifiAwareManager(private val context: Context) {
subscribeDiscoverySession?.close() subscribeDiscoverySession?.close()
wifiAwareSession?.close() wifiAwareSession?.close()
peerHandles.clear() peerHandles.clear()
coroutineScope.cancel()
} }
} }

View File

@@ -19,6 +19,14 @@ class ChatRepository @Inject constructor(
private val wifiAwareManager: WifiAwareManager, private val wifiAwareManager: WifiAwareManager,
private val messageDao: MessageDao 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 = "" private var currentRoomName: String = ""
@@ -47,11 +55,15 @@ class ChatRepository @Inject constructor(
init { init {
wifiAwareManager.initialize() wifiAwareManager.initialize()
setupWifiAwareCallbacks() // Only use Flow-based collection, not callbacks
collectWifiAwareFlows()
} }
private fun setupWifiAwareCallbacks() { private fun collectWifiAwareFlows() {
wifiAwareManager.setMessageCallback { userId, userName, content -> // Collect messages from Flow
repositoryScope.launch {
try {
wifiAwareManager.messageFlow.collect { (userId, userName, content) ->
val message = Message( val message = Message(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
userId = userId, userId = userId,
@@ -73,10 +85,34 @@ class ChatRepository @Inject constructor(
else -> _connectionState.value = ConnectionState.Connected("Active") else -> _connectionState.value = ConnectionState.Connected("Active")
} }
} }
}
} catch (e: Exception) {
android.util.Log.e("ChatRepository", "Error collecting message flow", e)
}
}
messageCallback?.invoke(userId, userName, content) // 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")
} }
} }
}
// Removed setupWifiAwareCallbacks - now using Flow-based collection only
fun startHostMode(roomName: String) { fun startHostMode(roomName: String) {
currentRoomName = roomName currentRoomName = roomName
@@ -87,10 +123,16 @@ class ChatRepository @Inject constructor(
} }
private fun loadMessagesFromDatabase(roomName: String) { private fun loadMessagesFromDatabase(roomName: String) {
CoroutineScope(Dispatchers.IO).launch { repositoryScope.launch {
try {
val storedMessages = messageDao.getMessagesForRoomOnce(roomName) val storedMessages = messageDao.getMessagesForRoomOnce(roomName)
.map { it.toMessage() } .map { it.toMessage() }
_messages.value = storedMessages _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) { 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 // Save to database
CoroutineScope(Dispatchers.IO).launch { repositoryScope.launch {
try {
messageDao.insertMessage(message.toEntity(currentRoomName)) 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) { fun setConnectionCallback(callback: (String, Boolean) -> Unit) {
connectionCallback = callback connectionCallback = callback
wifiAwareManager.setConnectionCallback { roomName, isConnected -> // Connection state is now handled by Flow collection
if (isConnected) {
currentRoomName = roomName
_connectionState.value = ConnectionState.Connected(roomName)
loadMessagesFromDatabase(roomName)
} else {
_connectionState.value = ConnectionState.Disconnected
}
callback(roomName, isConnected)
}
} }
fun stop() { fun stop() {
@@ -158,11 +197,12 @@ class ChatRepository @Inject constructor(
wifiAwareManager.stop() wifiAwareManager.stop()
_connectionState.value = ConnectionState.Disconnected _connectionState.value = ConnectionState.Disconnected
_connectedUsers.value = emptyList() _connectedUsers.value = emptyList()
repositoryScope.cancel()
} }
private fun startConnectionMonitoring() { private fun startConnectionMonitoring() {
connectionCheckJob?.cancel() connectionCheckJob?.cancel()
connectionCheckJob = CoroutineScope(Dispatchers.IO).launch { connectionCheckJob = repositoryScope.launch {
while (isActive) { while (isActive) {
delay(5000) // Check every 5 seconds delay(5000) // Check every 5 seconds
val timeSinceLastActivity = System.currentTimeMillis() - lastActivityTime val timeSinceLastActivity = System.currentTimeMillis() - lastActivityTime

View File

@@ -18,6 +18,10 @@ import com.mattintech.lchat.viewmodel.LobbyState
import com.mattintech.lchat.viewmodel.LobbyViewModel import com.mattintech.lchat.viewmodel.LobbyViewModel
import com.mattintech.lchat.utils.LOG_PREFIX import com.mattintech.lchat.utils.LOG_PREFIX
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
@AndroidEntryPoint @AndroidEntryPoint
class LobbyFragment : Fragment() { class LobbyFragment : Fragment() {
@@ -94,13 +98,21 @@ class LobbyFragment : Fragment() {
} }
private fun observeViewModel() { private fun observeViewModel() {
viewModel.savedUserName.observe(viewLifecycleOwner) { savedName -> // Collect saved user name
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.savedUserName.collect { savedName ->
if (!savedName.isNullOrEmpty() && binding.nameInput.text.isNullOrEmpty()) { if (!savedName.isNullOrEmpty() && binding.nameInput.text.isNullOrEmpty()) {
binding.nameInput.setText(savedName) binding.nameInput.setText(savedName)
} }
} }
}
}
viewModel.state.observe(viewLifecycleOwner) { state -> // Collect state
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { state ->
when (state) { when (state) {
is LobbyState.Idle -> { is LobbyState.Idle -> {
binding.noRoomsText.visibility = View.GONE binding.noRoomsText.visibility = View.GONE
@@ -124,18 +136,22 @@ class LobbyFragment : Fragment() {
} }
} }
} }
}
}
viewModel.events.observe(viewLifecycleOwner) { event -> // Collect events
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.events.collect { event ->
when (event) { when (event) {
is LobbyEvent.NavigateToChat -> { is LobbyEvent.NavigateToChat -> {
navigateToChat(event.roomName, event.userName, event.isHost) navigateToChat(event.roomName, event.userName, event.isHost)
viewModel.clearEvent()
} }
is LobbyEvent.ShowError -> { is LobbyEvent.ShowError -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
viewModel.clearEvent()
} }
null -> {} }
}
} }
} }
} }

View File

@@ -1,7 +1,5 @@
package com.mattintech.lchat.viewmodel package com.mattintech.lchat.viewmodel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.mattintech.lchat.data.Message import com.mattintech.lchat.data.Message
@@ -25,8 +23,8 @@ class ChatViewModel @Inject constructor(
private val chatRepository: ChatRepository private val chatRepository: ChatRepository
) : ViewModel() { ) : ViewModel() {
private val _state = MutableLiveData<ChatState>(ChatState.Connected) private val _state = MutableStateFlow<ChatState>(ChatState.Connected)
val state: LiveData<ChatState> = _state val state: StateFlow<ChatState> = _state.asStateFlow()
private val _messagesFlow = MutableStateFlow<Flow<List<Message>>>(flowOf(emptyList())) private val _messagesFlow = MutableStateFlow<Flow<List<Message>>>(flowOf(emptyList()))
@@ -85,11 +83,9 @@ class ChatViewModel @Inject constructor(
} }
fun disconnect() { fun disconnect() {
viewModelScope.launch {
chatRepository.stop() chatRepository.stop()
_state.value = ChatState.Disconnected _state.value = ChatState.Disconnected
} }
}
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()

View File

@@ -1,12 +1,16 @@
package com.mattintech.lchat.viewmodel package com.mattintech.lchat.viewmodel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.mattintech.lchat.repository.ChatRepository import com.mattintech.lchat.repository.ChatRepository
import com.mattintech.lchat.utils.PreferencesManager import com.mattintech.lchat.utils.PreferencesManager
import dagger.hilt.android.lifecycle.HiltViewModel 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 kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -32,14 +36,14 @@ class LobbyViewModel @Inject constructor(
private val preferencesManager: PreferencesManager private val preferencesManager: PreferencesManager
) : ViewModel() { ) : ViewModel() {
private val _state = MutableLiveData<LobbyState>(LobbyState.Idle) private val _state = MutableStateFlow<LobbyState>(LobbyState.Idle)
val state: LiveData<LobbyState> = _state val state: StateFlow<LobbyState> = _state.asStateFlow()
private val _events = MutableLiveData<LobbyEvent?>() private val _events = MutableSharedFlow<LobbyEvent>()
val events: LiveData<LobbyEvent?> = _events val events: SharedFlow<LobbyEvent> = _events.asSharedFlow()
private val _savedUserName = MutableLiveData<String?>() private val _savedUserName = MutableStateFlow<String?>(null)
val savedUserName: LiveData<String?> = _savedUserName val savedUserName: StateFlow<String?> = _savedUserName.asStateFlow()
init { init {
setupConnectionCallback() setupConnectionCallback()
@@ -64,12 +68,16 @@ class LobbyViewModel @Inject constructor(
fun startHostMode(roomName: String, userName: String) { fun startHostMode(roomName: String, userName: String) {
if (roomName.isBlank()) { if (roomName.isBlank()) {
_events.value = LobbyEvent.ShowError("Please enter a room name") viewModelScope.launch {
_events.emit(LobbyEvent.ShowError("Please enter a room name"))
}
return return
} }
if (userName.isBlank()) { if (userName.isBlank()) {
_events.value = LobbyEvent.ShowError("Please enter your name") viewModelScope.launch {
_events.emit(LobbyEvent.ShowError("Please enter your name"))
}
return return
} }
@@ -77,13 +85,15 @@ class LobbyViewModel @Inject constructor(
_state.value = LobbyState.Connecting _state.value = LobbyState.Connecting
preferencesManager.saveUserName(userName) preferencesManager.saveUserName(userName)
chatRepository.startHostMode(roomName) chatRepository.startHostMode(roomName)
_events.value = LobbyEvent.NavigateToChat(roomName, userName, true) _events.emit(LobbyEvent.NavigateToChat(roomName, userName, true))
} }
} }
fun startClientMode(userName: String) { fun startClientMode(userName: String) {
if (userName.isBlank()) { if (userName.isBlank()) {
_events.value = LobbyEvent.ShowError("Please enter your name") viewModelScope.launch {
_events.emit(LobbyEvent.ShowError("Please enter your name"))
}
return return
} }
@@ -96,11 +106,13 @@ class LobbyViewModel @Inject constructor(
fun onConnectedToRoom(roomName: String, userName: String) { fun onConnectedToRoom(roomName: String, userName: String) {
preferencesManager.saveUserName(userName) preferencesManager.saveUserName(userName)
_events.value = LobbyEvent.NavigateToChat(roomName, userName, false) viewModelScope.launch {
_events.emit(LobbyEvent.NavigateToChat(roomName, userName, false))
}
} }
fun clearEvent() { fun clearEvent() {
_events.value = null // SharedFlow doesn't need clearing, events are consumed once
} }
fun saveUserName(name: String) { fun saveUserName(name: String) {