From e4f5d9b89c42517f488f74c57c37a2064d16056d Mon Sep 17 00:00:00 2001 From: andrekir Date: Sat, 15 Jun 2024 08:08:13 -0300 Subject: [PATCH] refactor: migrate `ContactsFragment` to Compose --- .../mesh/model/ContactsViewModel.kt | 29 ++- .../com/geeksville/mesh/ui/ContactItem.kt | 138 ++++++++++ .../geeksville/mesh/ui/ContactsFragment.kt | 242 +++++++----------- .../res/layout/adapter_contact_layout.xml | 74 ------ app/src/main/res/layout/fragment_contacts.xml | 19 -- 5 files changed, 263 insertions(+), 239 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/ContactItem.kt delete mode 100644 app/src/main/res/layout/adapter_contact_layout.xml delete mode 100644 app/src/main/res/layout/fragment_contacts.xml diff --git a/app/src/main/java/com/geeksville/mesh/model/ContactsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/ContactsViewModel.kt index e6f201794..6131713a6 100644 --- a/app/src/main/java/com/geeksville/mesh/model/ContactsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/ContactsViewModel.kt @@ -2,7 +2,6 @@ package com.geeksville.mesh.model import android.app.Application import androidx.lifecycle.ViewModel -import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.geeksville.mesh.DataPacket import com.geeksville.mesh.R @@ -12,7 +11,12 @@ import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.repository.datastore.ChannelSetRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import java.text.DateFormat import java.util.Date @@ -49,6 +53,21 @@ class ContactsViewModel @Inject constructor( private val packetRepository: PacketRepository, ) : ViewModel(), Logging { + private val _selectedContacts = MutableStateFlow(emptySet()) + val selectedContacts get() = _selectedContacts.asStateFlow() + + fun updateSelectedContacts(contact: String) = _selectedContacts.updateAndGet { + if (it.contains(contact)) { + it.minus(contact) + } else { + it.plus(contact) + } + } + + fun clearSelectedContacts() { + _selectedContacts.value = emptySet() + } + val contactList = combine( nodeDB.myNodeInfo, packetRepository.getContacts(), @@ -63,7 +82,7 @@ class ContactsViewModel @Inject constructor( contactKey to Packet(0L, myNodeNum, 1, contactKey, 0L, true, data) } - (placeholder + contacts).values.map { packet -> + (contacts + (placeholder - contacts.keys)).values.map { packet -> val data = packet.data val contactKey = packet.contact_key @@ -92,7 +111,11 @@ class ContactsViewModel @Inject constructor( isMuted = settings[contactKey]?.isMuted == true, ) } - }.asLiveData() + }.stateIn( + scope = viewModelScope, + started = WhileSubscribed(5_000), + initialValue = emptyList(), + ) fun setMuteUntil(contacts: List, until: Long) = viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) diff --git a/app/src/main/java/com/geeksville/mesh/ui/ContactItem.kt b/app/src/main/java/com/geeksville/mesh/ui/ContactItem.kt new file mode 100644 index 000000000..4a4db35ec --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/ContactItem.kt @@ -0,0 +1,138 @@ +package com.geeksville.mesh.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Chip +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R +import com.geeksville.mesh.model.Contact +import com.geeksville.mesh.ui.theme.AppTheme + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun ContactItem( + contact: Contact, + modifier: Modifier = Modifier, +) = with(contact) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + elevation = 2.dp, + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Chip( + onClick = { }, + modifier = Modifier + .width(72.dp) + .padding(end = 8.dp), + ) { + Text( + text = shortName, + modifier = Modifier.fillMaxWidth(), + fontSize = MaterialTheme.typography.button.fontSize, + fontWeight = FontWeight.Normal, + textAlign = TextAlign.Center, + ) + } + Column( + modifier = Modifier.weight(1f), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = longName, + fontSize = MaterialTheme.typography.button.fontSize, + ) + Text( + text = lastMessageTime.orEmpty(), + fontSize = MaterialTheme.typography.button.fontSize, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = lastMessageText.orEmpty(), + modifier = Modifier.weight(1f), + color = MaterialTheme.colors.onSurface, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + AnimatedVisibility(visible = isMuted) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_twotone_volume_off_24), + contentDescription = null, + ) + } + AnimatedVisibility(visible = unreadCount > 0) { + Text( + text = unreadCount.toString(), + modifier = Modifier + .background(MaterialTheme.colors.primary, shape = CircleShape) + .padding(horizontal = 6.dp, vertical = 3.dp), + color = MaterialTheme.colors.onPrimary, + style = MaterialTheme.typography.caption, + ) + } + } + } + } + } +} + +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ContactItemPreview() { + AppTheme { + ContactItem( + contact = Contact( + contactKey = "0^all", + shortName = stringResource(R.string.some_username), + longName = stringResource(R.string.unknown_username), + lastMessageTime = "3 minutes ago", + lastMessageText = stringResource(R.string.sample_message), + unreadCount = 2, + messageCount = 10, + isMuted = true, + ), + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt index 28499ba74..dfc507e9c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt @@ -1,22 +1,36 @@ package com.geeksville.mesh.ui -import android.graphics.Color -import android.graphics.drawable.GradientDrawable import android.os.Bundle -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible -import androidx.fragment.app.activityViewModels -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp +import androidx.fragment.app.viewModels +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.android.Logging import com.geeksville.mesh.R -import com.geeksville.mesh.databinding.AdapterContactLayoutBinding -import com.geeksville.mesh.databinding.FragmentContactsBinding import com.geeksville.mesh.model.Contact import com.geeksville.mesh.model.ContactsViewModel +import com.geeksville.mesh.ui.theme.AppTheme import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import java.util.concurrent.TimeUnit @@ -26,116 +40,36 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { private val actionModeCallback: ActionModeCallback = ActionModeCallback() private var actionMode: ActionMode? = null - private var _binding: FragmentContactsBinding? = null + private val model: ContactsViewModel by viewModels() - // This property is only valid between onCreateView and onDestroyView. - private val binding get() = _binding!! + private val contacts get() = model.contactList.value + private val selectedList get() = model.selectedContacts.value.toList() - private val model: ContactsViewModel by activityViewModels() + private val selectedContacts get() = contacts.filter { it.contactKey in selectedList } + private val isAllMuted get() = selectedContacts.all { it.isMuted } + private val selectedCount get() = selectedContacts.sumOf { it.messageCount } - // Provide a direct reference to each of the views within a data item - // Used to cache the views within the item layout for fast access - class ViewHolder(itemView: AdapterContactLayoutBinding) : - RecyclerView.ViewHolder(itemView.root) { - val shortName = itemView.shortName - val longName = itemView.longName - val lastMessageTime = itemView.lastMessageTime - val lastMessageText = itemView.lastMessageText - val mutedIcon = itemView.mutedIcon + private fun onClick(contact: Contact) { + if (actionMode != null) { + onLongClick(contact) + } else { + debug("calling MessagesFragment filter:${contact.contactKey}") + parentFragmentManager.navigateToMessages(contact.contactKey, contact.longName) + } } - private val contactsAdapter = object : RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val inflater = LayoutInflater.from(requireContext()) - - // Inflate the custom layout - val contactsView = AdapterContactLayoutBinding.inflate(inflater, parent, false) - - // Return a new holder instance - return ViewHolder(contactsView) + private fun onLongClick(contact: Contact) { + if (actionMode == null) { + actionMode = (activity as AppCompatActivity).startSupportActionMode(actionModeCallback) } - var contacts = arrayOf() - var selectedList = ArrayList() - - private val selectedContacts get() = contacts.filter { it.contactKey in selectedList } - val isAllMuted get() = selectedContacts.all { it.isMuted } - val selectedCount get() = selectedContacts.sumOf { it.messageCount } - - override fun getItemCount(): Int = contacts.size - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val contact = contacts[position] - - holder.shortName.text = contact.shortName - holder.longName.text = contact.longName - holder.lastMessageText.text = contact.lastMessageText - - if (contact.lastMessageTime != null) { - holder.lastMessageTime.visibility = View.VISIBLE - holder.lastMessageTime.text = contact.lastMessageTime - } else holder.lastMessageTime.visibility = View.INVISIBLE - - holder.mutedIcon.isVisible = contact.isMuted - - holder.itemView.setOnLongClickListener { - clickItem(holder, contact.contactKey) - if (actionMode == null) { - actionMode = - (activity as AppCompatActivity).startSupportActionMode(actionModeCallback) - } - true - } - holder.itemView.setOnClickListener { - if (actionMode != null) clickItem(holder, contact.contactKey) - else { - debug("calling MessagesFragment filter:${contact.contactKey}") - parentFragmentManager.navigateToMessages(contact.contactKey, contact.longName) - } - } - - if (selectedList.contains(contact.contactKey)) { - holder.itemView.background = GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = 32f - setColor(Color.rgb(127, 127, 127)) - } - } else { - holder.itemView.background = GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = 32f - setColor( - ContextCompat.getColor( - holder.itemView.context, - R.color.colorAdvancedBackground - ) - ) - } - } - } - - private fun clickItem(holder: ViewHolder, contactKey: String) { - val position = holder.bindingAdapterPosition - if (!selectedList.contains(contactKey)) { - selectedList.add(contactKey) - } else { - selectedList.remove(contactKey) - } - if (selectedList.isEmpty()) { - // finish action mode when no items selected - actionMode?.finish() - } else { - // show total items selected on action mode title - actionMode?.title = selectedList.size.toString() - } - actionMode?.invalidate() - notifyItemChanged(position) - } - - fun onContactsChanged(contacts: List) { - this.contacts = contacts.toTypedArray() - notifyDataSetChanged() // FIXME, this is super expensive and redraws all nodes + val selected = model.updateSelectedContacts(contact.contactKey) + if (selected.isEmpty()) { + // finish action mode when no items selected + actionMode?.finish() + } else { + // show total items selected on action mode title + actionMode?.title = selected.size.toString() } } @@ -145,22 +79,17 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { } override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, + inflater: LayoutInflater, + container: ViewGroup?, savedInstanceState: Bundle? ): View { - _binding = FragmentContactsBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.contactsView.adapter = contactsAdapter - binding.contactsView.layoutManager = LinearLayoutManager(requireContext()) - - model.contactList.observe(viewLifecycleOwner) { - debug("New contacts received: ${it.size}") - contactsAdapter.onContactsChanged(it) + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + ContactsScreen(model, ::onClick, ::onLongClick) + } + } } } @@ -168,7 +97,6 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { super.onDestroyView() actionMode?.finish() actionMode = null - _binding = null } private inner class ActionModeCallback : ActionMode.Callback { @@ -181,7 +109,7 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { menu.findItem(R.id.muteButton).setIcon( - if (contactsAdapter.isAllMuted) { + if (isAllMuted) { R.drawable.ic_twotone_volume_up_24 } else { R.drawable.ic_twotone_volume_off_24 @@ -192,8 +120,8 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { when (item.itemId) { - R.id.muteButton -> if (contactsAdapter.isAllMuted) { - model.setMuteUntil(contactsAdapter.selectedList.toList(), 0L) + R.id.muteButton -> if (isAllMuted) { + model.setMuteUntil(selectedList, 0L) mode.finish() } else { var muteUntil: Long = Long.MAX_VALUE @@ -215,7 +143,7 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { } .setPositiveButton(getString(R.string.okay)) { _, _ -> debug("User clicked muteButton") - model.setMuteUntil(contactsAdapter.selectedList.toList(), muteUntil) + model.setMuteUntil(selectedList, muteUntil) mode.finish() } .setNeutralButton(R.string.cancel) { _, _ -> @@ -224,7 +152,6 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { } R.id.deleteButton -> { - val selectedCount = contactsAdapter.selectedCount val deleteMessagesString = resources.getQuantityString( R.plurals.delete_messages, selectedCount, @@ -234,7 +161,7 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { .setMessage(deleteMessagesString) .setPositiveButton(getString(R.string.delete)) { _, _ -> debug("User clicked deleteButton") - model.deleteContacts(contactsAdapter.selectedList.toList()) + model.deleteContacts(selectedList) mode.finish() } .setNeutralButton(R.string.cancel) { _, _ -> @@ -243,27 +170,56 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { } R.id.selectAllButton -> { // if all selected -> unselect all - if (contactsAdapter.selectedList.size == contactsAdapter.contacts.size) { - contactsAdapter.selectedList.clear() + if (selectedList.size == contacts.size) { + model.clearSelectedContacts() mode.finish() } else { // else --> select all - contactsAdapter.selectedList.clear() - contactsAdapter.contacts.forEach { - contactsAdapter.selectedList.add(it.contactKey) + model.clearSelectedContacts() + contacts.forEach { + model.updateSelectedContacts(it.contactKey) } + actionMode?.title = contacts.size.toString() } - actionMode?.title = contactsAdapter.selectedList.size.toString() - contactsAdapter.notifyDataSetChanged() } } return true } override fun onDestroyActionMode(mode: ActionMode) { - contactsAdapter.selectedList.clear() - contactsAdapter.notifyDataSetChanged() + model.clearSelectedContacts() actionMode = null } } } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ContactsScreen( + model: ContactsViewModel = hiltViewModel(), + onClick: (Contact) -> Unit, + onLongClick: (Contact) -> Unit, +) { + val contacts by model.contactList.collectAsStateWithLifecycle(emptyList()) + val selectedKeys by model.selectedContacts.collectAsStateWithLifecycle() + // val inSelectionMode by remember { derivedStateOf { selectedContacts.isNotEmpty() } } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(6.dp), + ) { + items(contacts, key = { it.contactKey }) { contact -> + val selected = selectedKeys.contains(contact.contactKey) + ContactItem( + contact = contact, + modifier = Modifier + .background(color = if (selected) Color.Gray else MaterialTheme.colors.background) + .combinedClickable( + onClick = { onClick(contact) }, + onLongClick = { onLongClick(contact) }, + ) + ) + } + } +} diff --git a/app/src/main/res/layout/adapter_contact_layout.xml b/app/src/main/res/layout/adapter_contact_layout.xml deleted file mode 100644 index 147959dea..000000000 --- a/app/src/main/res/layout/adapter_contact_layout.xml +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_contacts.xml b/app/src/main/res/layout/fragment_contacts.xml deleted file mode 100644 index a96d827ca..000000000 --- a/app/src/main/res/layout/fragment_contacts.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - \ No newline at end of file