refactoring to a proper MVVM

This commit is contained in:
2025-07-03 20:21:11 -04:00
parent d564cec7cf
commit 4719344ae8
10 changed files with 472 additions and 110 deletions

75
TODO.md Normal file
View File

@@ -0,0 +1,75 @@
# LocalChat (lchat) - Improvement Plan
## Phase 1: Architecture Foundation
### 1.1 MVVM with ViewModels and Repository Pattern ✅
- [x] Create ViewModels for each screen (LobbyViewModel, ChatViewModel)
- [x] Extract business logic from Fragments to ViewModels
- [x] Create Repository layer for data operations
- [x] Implement proper state management with LiveData/StateFlow
- [x] Add ViewModelFactory if needed
### 1.2 Dependency Injection with Hilt
- [ ] Add Hilt dependencies
- [ ] Set up Hilt modules for WifiAwareManager
- [ ] Convert singletons to proper DI
- [ ] Inject ViewModels using Hilt
## Phase 2: Core UX Improvements
### 2.1 Connection Status Management
- [ ] Add connection state to ViewModels
- [ ] Create UI indicator for connection status
- [ ] Show real-time connection state changes
- [ ] Add connection error messages
### 2.2 User List Feature
- [ ] Track connected users in Repository
- [ ] Add UI to display active users
- [ ] Handle user join/leave events
- [ ] Show user count in chat header
## Phase 3: Data Persistence
### 3.1 Room Database Setup
- [ ] Add Room dependencies
- [ ] Create Message and User entities
- [ ] Implement DAOs for data access
- [ ] Create database migrations
### 3.2 Message Persistence
- [ ] Store messages in Room database
- [ ] Load message history on app restart
- [ ] Implement message sync logic
- [ ] Add message timestamps
## Phase 4: Reliability Improvements
### 4.1 Reconnection Handling
- [ ] Detect connection drops
- [ ] Implement exponential backoff retry
- [ ] Preserve message queue during disconnection
- [ ] Auto-reconnect when network available
### 4.2 Network State Monitoring
- [ ] Monitor WiFi state changes
- [ ] Handle app lifecycle properly
- [ ] Save and restore connection state
## Phase 5: Advanced Features
### 5.1 Background Service
- [ ] Create foreground service for persistent connection
- [ ] Handle Doze mode and battery optimization
- [ ] Add notification for active chat
- [ ] Implement proper service lifecycle
### 5.2 Additional Features
- [ ] Message delivery status
- [ ] Typing indicators
- [ ] File/image sharing support
- [ ] Message encryption improvements
## Current Status
- 🚀 Starting with Phase 1.1 - MVVM Architecture
- Target: Create a maintainable, testable architecture

View File

@@ -2,9 +2,9 @@ package com.mattintech.lchat.data
data class Message(
val id: String,
val senderId: String,
val senderName: String,
val userId: String,
val userName: String,
val content: String,
val timestamp: Long,
val isLocal: Boolean = false
val isOwnMessage: Boolean = false
)

View File

@@ -1,20 +0,0 @@
package com.mattintech.lchat.network
import android.content.Context
object WifiAwareManagerSingleton {
private var instance: WifiAwareManager? = null
fun getInstance(context: Context): WifiAwareManager {
if (instance == null) {
instance = WifiAwareManager(context.applicationContext)
instance!!.initialize()
}
return instance!!
}
fun reset() {
instance?.stop()
instance = null
}
}

View File

@@ -0,0 +1,111 @@
package com.mattintech.lchat.repository
import android.content.Context
import com.mattintech.lchat.data.Message
import com.mattintech.lchat.network.WifiAwareManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.UUID
class ChatRepository private constructor(context: Context) {
companion object {
@Volatile
private var INSTANCE: ChatRepository? = null
fun getInstance(context: Context): ChatRepository {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: ChatRepository(context.applicationContext).also { INSTANCE = it }
}
}
}
private val wifiAwareManager = WifiAwareManager(context)
private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages: StateFlow<List<Message>> = _messages.asStateFlow()
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _connectedUsers = MutableStateFlow<List<String>>(emptyList())
val connectedUsers: StateFlow<List<String>> = _connectedUsers.asStateFlow()
private var messageCallback: ((String, String, String) -> Unit)? = null
private var connectionCallback: ((String, Boolean) -> Unit)? = null
init {
wifiAwareManager.initialize()
setupWifiAwareCallbacks()
}
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)
messageCallback?.invoke(userId, userName, content)
}
}
fun startHostMode(roomName: String) {
wifiAwareManager.startHostMode(roomName)
_connectionState.value = ConnectionState.Hosting(roomName)
}
fun startClientMode() {
wifiAwareManager.startClientMode()
_connectionState.value = ConnectionState.Searching
}
fun sendMessage(userId: String, userName: String, content: String) {
val message = Message(
id = UUID.randomUUID().toString(),
userId = userId,
userName = userName,
content = content,
timestamp = System.currentTimeMillis(),
isOwnMessage = true
)
addMessage(message)
wifiAwareManager.sendMessage(userId, userName, content)
}
private fun addMessage(message: Message) {
_messages.value = _messages.value + message
}
fun clearMessages() {
_messages.value = emptyList()
}
fun setMessageCallback(callback: (String, String, String) -> Unit) {
messageCallback = callback
}
fun setConnectionCallback(callback: (String, Boolean) -> Unit) {
connectionCallback = callback
wifiAwareManager.setConnectionCallback(callback)
}
fun stop() {
wifiAwareManager.stop()
_connectionState.value = ConnectionState.Disconnected
_connectedUsers.value = emptyList()
}
sealed class ConnectionState {
object Disconnected : ConnectionState()
object Searching : ConnectionState()
data class Hosting(val roomName: String) : ConnectionState()
data class Connected(val roomName: String) : ConnectionState()
data class Error(val message: String) : ConnectionState()
}
}

View File

@@ -6,15 +6,16 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
import com.mattintech.lchat.data.Message
import com.mattintech.lchat.databinding.FragmentChatBinding
import com.mattintech.lchat.network.WifiAwareManager
import com.mattintech.lchat.network.WifiAwareManagerSingleton
import com.mattintech.lchat.ui.adapters.MessageAdapter
import com.mattintech.lchat.utils.LOG_PREFIX
import java.util.UUID
import com.mattintech.lchat.viewmodel.ChatViewModel
import com.mattintech.lchat.viewmodel.ViewModelFactory
import kotlinx.coroutines.launch
class ChatFragment : Fragment() {
@@ -26,10 +27,8 @@ class ChatFragment : Fragment() {
private val binding get() = _binding!!
private val args: ChatFragmentArgs by navArgs()
private lateinit var wifiAwareManager: WifiAwareManager
private lateinit var viewModel: ChatViewModel
private lateinit var messageAdapter: MessageAdapter
private val messages = mutableListOf<Message>()
private val userId = UUID.randomUUID().toString()
override fun onCreateView(
inflater: LayoutInflater,
@@ -45,8 +44,12 @@ class ChatFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
Log.d(TAG, "onViewCreated - room: ${args.roomName}, user: ${args.userName}, isHost: ${args.isHost}")
val factory = ViewModelFactory(requireContext())
viewModel = ViewModelProvider(this, factory)[ChatViewModel::class.java]
viewModel.initialize(args.roomName, args.userName, args.isHost)
setupUI()
setupWifiAware()
observeViewModel()
}
private fun setupUI() {
@@ -68,58 +71,35 @@ class ChatFragment : Fragment() {
}
}
private fun setupWifiAware() {
wifiAwareManager = WifiAwareManagerSingleton.getInstance(requireContext())
wifiAwareManager.setMessageCallback { senderId, senderName, content ->
Log.d(TAG, "Message received - from: $senderName, content: $content")
val message = Message(
id = UUID.randomUUID().toString(),
senderId = senderId,
senderName = senderName,
content = content,
timestamp = System.currentTimeMillis(),
isLocal = senderId == userId
)
activity?.runOnUiThread {
messages.add(message)
messageAdapter.submitList(messages.toList())
binding.messagesRecyclerView.smoothScrollToPosition(messages.size - 1)
private fun observeViewModel() {
lifecycleScope.launch {
viewModel.messages.collect { messages ->
messageAdapter.submitList(messages)
if (messages.isNotEmpty()) {
binding.messagesRecyclerView.smoothScrollToPosition(messages.size - 1)
}
}
}
// No need to start host mode here - already started in LobbyFragment
Log.d(TAG, "Chat setup complete - isHost: ${args.isHost}, room: ${args.roomName}")
lifecycleScope.launch {
viewModel.connectionState.collect { state ->
Log.d(TAG, "Connection state: $state")
// Handle connection state changes if needed
}
}
}
private fun sendMessage() {
val content = binding.messageInput.text?.toString()?.trim()
if (content.isNullOrEmpty()) return
val message = Message(
id = UUID.randomUUID().toString(),
senderId = userId,
senderName = args.userName,
content = content,
timestamp = System.currentTimeMillis(),
isLocal = true
)
messages.add(message)
messageAdapter.submitList(messages.toList())
binding.messagesRecyclerView.smoothScrollToPosition(messages.size - 1)
Log.d(TAG, "Sending message: $content")
wifiAwareManager.sendMessage(userId, args.userName, content)
viewModel.sendMessage(content)
binding.messageInput.text?.clear()
}
override fun onDestroyView() {
super.onDestroyView()
Log.d(TAG, "onDestroyView")
// Don't stop WifiAwareManager here - it's shared across fragments
_binding = null
}
}

View File

@@ -11,8 +11,10 @@ import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import com.mattintech.lchat.R
import com.mattintech.lchat.databinding.FragmentLobbyBinding
import com.mattintech.lchat.network.WifiAwareManager
import com.mattintech.lchat.network.WifiAwareManagerSingleton
import com.mattintech.lchat.viewmodel.LobbyEvent
import com.mattintech.lchat.viewmodel.LobbyState
import com.mattintech.lchat.viewmodel.LobbyViewModel
import com.mattintech.lchat.viewmodel.ViewModelFactory
import com.mattintech.lchat.utils.LOG_PREFIX
class LobbyFragment : Fragment() {
@@ -24,7 +26,7 @@ class LobbyFragment : Fragment() {
private var _binding: FragmentLobbyBinding? = null
private val binding get() = _binding!!
private lateinit var wifiAwareManager: WifiAwareManager
private lateinit var viewModel: LobbyViewModel
override fun onCreateView(
inflater: LayoutInflater,
@@ -40,10 +42,11 @@ class LobbyFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
Log.d(TAG, "onViewCreated")
Log.d(TAG, "Getting WifiAwareManager singleton")
wifiAwareManager = WifiAwareManagerSingleton.getInstance(requireContext())
val factory = ViewModelFactory(requireContext())
viewModel = ViewModelProvider(this, factory)[LobbyViewModel::class.java]
setupUI()
observeViewModel()
}
private fun setupUI() {
@@ -64,55 +67,61 @@ class LobbyFragment : Fragment() {
}
binding.actionButton.setOnClickListener {
val userName = binding.nameInput.text?.toString()?.trim()
if (userName.isNullOrEmpty()) {
Toast.makeText(context, "Please enter your name", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
val userName = binding.nameInput.text?.toString()?.trim() ?: ""
when (binding.modeRadioGroup.checkedRadioButtonId) {
R.id.hostRadio -> {
val roomName = binding.roomInput.text?.toString()?.trim()
if (roomName.isNullOrEmpty()) {
Toast.makeText(context, "Please enter a room name", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
startHostMode(roomName, userName)
val roomName = binding.roomInput.text?.toString()?.trim() ?: ""
viewModel.startHostMode(roomName, userName)
}
R.id.clientRadio -> {
startClientMode(userName)
viewModel.startClientMode(userName)
}
}
}
}
private fun observeViewModel() {
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)
}
}
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()
}
}
}
wifiAwareManager.setConnectionCallback { roomName, isConnected ->
Log.d(TAG, "Connection callback - room: $roomName, connected: $isConnected")
activity?.runOnUiThread {
if (isConnected && binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) {
val userName = binding.nameInput.text?.toString()?.trim() ?: ""
navigateToChat(roomName, userName, false)
} else if (!isConnected && binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) {
binding.noRoomsText.text = "Failed to connect to $roomName. Ensure Wi-Fi is enabled on both devices."
Toast.makeText(context, "Connection failed. Check Wi-Fi is enabled.", Toast.LENGTH_LONG).show()
viewModel.events.observe(viewLifecycleOwner) { event ->
when (event) {
is LobbyEvent.NavigateToChat -> {
navigateToChat(event.roomName, event.userName, event.isHost)
viewModel.clearEvent()
}
is LobbyEvent.ShowError -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
viewModel.clearEvent()
}
null -> {}
}
}
}
private fun startHostMode(roomName: String, userName: String) {
Log.d(TAG, "Starting host mode - room: $roomName, user: $userName")
wifiAwareManager.startHostMode(roomName)
navigateToChat(roomName, userName, true)
}
private fun startClientMode(userName: String) {
Log.d(TAG, "Starting client mode - user: $userName")
binding.noRoomsText.visibility = View.VISIBLE
binding.noRoomsText.text = getString(R.string.connecting)
wifiAwareManager.startClientMode()
}
private fun navigateToChat(roomName: String, userName: String, isHost: Boolean) {
Log.d(TAG, "Navigating to chat - room: $roomName, user: $userName, isHost: $isHost")
val action = LobbyFragmentDirections.actionLobbyToChat(

View File

@@ -35,12 +35,12 @@ class MessageAdapter : ListAdapter<Message, MessageAdapter.MessageViewHolder>(Me
) : RecyclerView.ViewHolder(binding.root) {
fun bind(message: Message) {
binding.senderName.text = message.senderName
binding.senderName.text = message.userName
binding.messageContent.text = message.content
binding.timestamp.text = timeFormat.format(Date(message.timestamp))
val layoutParams = binding.messageCard.layoutParams as ConstraintLayout.LayoutParams
if (message.isLocal) {
if (message.isOwnMessage) {
layoutParams.startToStart = ConstraintLayout.LayoutParams.UNSET
layoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
binding.messageCard.setCardBackgroundColor(

View File

@@ -0,0 +1,89 @@
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
import com.mattintech.lchat.repository.ChatRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.UUID
sealed class ChatState {
object Connected : ChatState()
object Disconnected : ChatState()
data class Error(val message: String) : ChatState()
}
class ChatViewModel(
private val chatRepository: ChatRepository
) : ViewModel() {
private val _state = MutableLiveData<ChatState>(ChatState.Connected)
val state: LiveData<ChatState> = _state
val messages: StateFlow<List<Message>> = chatRepository.messages
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
val connectionState = chatRepository.connectionState
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ChatRepository.ConnectionState.Disconnected
)
val connectedUsers = chatRepository.connectedUsers
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
private var currentUserId: String = ""
private var currentUserName: String = ""
private var currentRoomName: String = ""
private var isHost: Boolean = false
fun initialize(roomName: String, userName: String, isHost: Boolean) {
this.currentRoomName = roomName
this.currentUserName = userName
this.isHost = isHost
this.currentUserId = UUID.randomUUID().toString()
// Setup message callback if needed for additional processing
chatRepository.setMessageCallback { userId, userName, content ->
// Can add additional message processing here if needed
}
}
fun sendMessage(content: String) {
if (content.isBlank()) return
viewModelScope.launch {
chatRepository.sendMessage(currentUserId, currentUserName, content)
}
}
fun getRoomInfo(): Triple<String, String, Boolean> {
return Triple(currentRoomName, currentUserName, isHost)
}
fun disconnect() {
viewModelScope.launch {
chatRepository.stop()
_state.value = ChatState.Disconnected
}
}
override fun onCleared() {
super.onCleared()
disconnect()
}
}

View File

@@ -0,0 +1,94 @@
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 kotlinx.coroutines.launch
sealed class LobbyState {
object Idle : LobbyState()
object Connecting : LobbyState()
data class Connected(val roomName: String) : LobbyState()
data class Error(val message: String) : LobbyState()
}
sealed class LobbyEvent {
data class NavigateToChat(
val roomName: String,
val userName: String,
val isHost: Boolean
) : LobbyEvent()
data class ShowError(val message: String) : LobbyEvent()
}
class LobbyViewModel(
private val chatRepository: ChatRepository
) : ViewModel() {
private val _state = MutableLiveData<LobbyState>(LobbyState.Idle)
val state: LiveData<LobbyState> = _state
private val _events = MutableLiveData<LobbyEvent?>()
val events: LiveData<LobbyEvent?> = _events
init {
setupConnectionCallback()
}
private fun setupConnectionCallback() {
chatRepository.setConnectionCallback { roomName, isConnected ->
viewModelScope.launch {
if (isConnected) {
_state.value = LobbyState.Connected(roomName)
} else {
_state.value = LobbyState.Error("Failed to connect to $roomName. Ensure Wi-Fi is enabled on both devices.")
}
}
}
}
fun startHostMode(roomName: String, userName: String) {
if (roomName.isBlank()) {
_events.value = LobbyEvent.ShowError("Please enter a room name")
return
}
if (userName.isBlank()) {
_events.value = LobbyEvent.ShowError("Please enter your name")
return
}
viewModelScope.launch {
_state.value = LobbyState.Connecting
chatRepository.startHostMode(roomName)
_events.value = LobbyEvent.NavigateToChat(roomName, userName, true)
}
}
fun startClientMode(userName: String) {
if (userName.isBlank()) {
_events.value = LobbyEvent.ShowError("Please enter your name")
return
}
viewModelScope.launch {
_state.value = LobbyState.Connecting
chatRepository.startClientMode()
}
}
fun onConnectedToRoom(roomName: String, userName: String) {
_events.value = LobbyEvent.NavigateToChat(roomName, userName, false)
}
fun clearEvent() {
_events.value = null
}
override fun onCleared() {
super.onCleared()
// Clean up resources if needed
}
}

View File

@@ -0,0 +1,24 @@
package com.mattintech.lchat.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.mattintech.lchat.repository.ChatRepository
class ViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
private val chatRepository by lazy { ChatRepository.getInstance(context) }
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when {
modelClass.isAssignableFrom(LobbyViewModel::class.java) -> {
LobbyViewModel(chatRepository) as T
}
modelClass.isAssignableFrom(ChatViewModel::class.java) -> {
ChatViewModel(chatRepository) as T
}
else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
}
}