refactor: migrate `ContactsFragment` to Compose

pull/1103/head
andrekir 2024-06-15 08:08:13 -03:00 zatwierdzone przez Andre K
rodzic 76764b9351
commit e4f5d9b89c
5 zmienionych plików z 263 dodań i 239 usunięć

Wyświetl plik

@ -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<String>())
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<String>, until: Long) = viewModelScope.launch(Dispatchers.IO) {
packetRepository.setMuteUntil(contacts, until)

Wyświetl plik

@ -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,
),
)
}
}

Wyświetl plik

@ -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<ViewHolder>() {
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<Contact>()
var selectedList = ArrayList<String>()
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<Contact>) {
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) },
)
)
}
}
}

Wyświetl plik

@ -1,74 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.card.MaterialCardView
style="@style/Widget.App.CardView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:cardUseCompatPadding="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.chip.Chip
android:id="@+id/shortName"
android:layout_width="72dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/some_username"
android:textAlignment="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/longName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/unknown_username"
app:layout_constraintStart_toEndOf="@+id/shortName"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/lastMessageText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:maxLines="2"
android:text="@string/sample_message"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/mutedIcon"
app:layout_constraintStart_toEndOf="@id/shortName"
app:layout_constraintTop_toBottomOf="@id/longName" />
<TextView
android:id="@+id/lastMessageTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:contentDescription="@string/message_reception_time"
android:text="3 minutes ago"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/mutedIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_margin="8dp"
android:contentDescription="@string/mute"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:srcCompat="@drawable/ic_twotone_volume_off_24" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>

Wyświetl plik

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/contactsView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>