refactor: migrate UsersFragment to Compose

This commit is contained in:
andrekir 2024-06-23 08:24:29 -03:00
parent 098c89f45c
commit db500c5200
8 changed files with 184 additions and 474 deletions

View file

@ -518,13 +518,6 @@ class MainActivity : AppCompatActivity(), Logging {
override fun onStop() {
unbindMeshService()
model.connectionState.removeObservers(this)
bluetoothViewModel.enabled.removeObservers(this)
model.requestChannelUrl.removeObservers(this)
model.snackbarText.removeObservers(this)
model.currentTab.removeObservers(this)
super.onStop()
}
@ -571,6 +564,17 @@ class MainActivity : AppCompatActivity(), Logging {
binding.tabLayout.getTabAt(it)?.select()
}
model.tracerouteResponse.observe(this) { response ->
MaterialAlertDialogBuilder(this)
.setCancelable(false)
.setTitle(R.string.traceroute)
.setMessage(response ?: return@observe)
.setPositiveButton(R.string.okay) { _, _ -> }
.show()
model.clearTracerouteResponse()
}
try {
bindMeshService()
} catch (ex: BindFailedException) {
@ -646,10 +650,7 @@ class MainActivity : AppCompatActivity(), Logging {
return true
}
R.id.radio_config -> {
supportFragmentManager.beginTransaction()
.add(R.id.mainActivityLayout, DeviceSettingsFragment())
.addToBackStack(null)
.commit()
supportFragmentManager.navigateToRadioConfig()
return true
}
R.id.save_messages_csv -> {

View file

@ -102,6 +102,10 @@ data class NodesUiState(
val sort: NodeSortOption = NodeSortOption.LAST_HEARD,
val filter: String = "",
val includeUnknown: Boolean = false,
val gpsFormat:Int = 0,
val distanceUnits:Int = 0,
val tempInFahrenheit:Boolean = false,
val ignoreIncomingList: List<Int> = emptyList(),
) {
companion object {
val Empty = NodesUiState()
@ -160,11 +164,16 @@ class UIViewModel @Inject constructor(
nodeFilterText,
nodeSortOption,
includeUnknown,
) { filter, sort, includeUnknown ->
radioConfigRepository.deviceProfileFlow,
) { filter, sort, includeUnknown, profile ->
NodesUiState(
sort = sort,
filter = filter,
includeUnknown = includeUnknown,
gpsFormat = profile.config.display.gpsFormat.number,
distanceUnits = profile.config.display.units.number,
tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit,
ignoreIncomingList = profile.config.lora.ignoreIncomingList,
)
}.stateIn(
scope = viewModelScope,
@ -580,5 +589,4 @@ class UIViewModel @Inject constructor(
fun setNodeFilterText(text: String) {
nodeFilterText.value = text
}
}

View file

@ -47,7 +47,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.setFragmentResultListener
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -91,6 +92,16 @@ import com.geeksville.mesh.ui.components.config.UserConfigItemList
import com.google.accompanist.themeadapter.appcompat.AppCompatTheme
import dagger.hilt.android.AndroidEntryPoint
internal fun FragmentManager.navigateToRadioConfig(destNum: Int? = null) {
val radioConfigFragment = DeviceSettingsFragment().apply {
arguments = bundleOf("destNum" to destNum)
}
beginTransaction()
.replace(R.id.mainActivityLayout, radioConfigFragment)
.addToBackStack(null)
.commit()
}
@AndroidEntryPoint
class DeviceSettingsFragment : ScreenFragment("Radio Configuration"), Logging {
@ -101,10 +112,8 @@ class DeviceSettingsFragment : ScreenFragment("Radio Configuration"), Logging {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
setFragmentResultListener("requestKey") { _, bundle ->
val destNum = bundle.getInt("destNum")
model.setDestNum(destNum)
}
val destNum = arguments?.getInt("destNum")
model.setDestNum(destNum)
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)

View file

@ -3,7 +3,7 @@ package com.geeksville.mesh.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.repeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@ -25,8 +25,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -58,9 +56,6 @@ fun NodeInfo(
onClicked: () -> Unit = {},
blinking: Boolean = false,
) {
val BLINK_DURATION = 250
val unknownShortName = stringResource(id = R.string.unknown_node_short_name)
val unknownLongName = stringResource(id = R.string.unknown_username)
@ -72,13 +67,14 @@ fun NodeInfo(
val highlight = Color(0x33FFFFFF)
val bgColor by animateColorAsState(
targetValue = if (blinking) highlight else Color.Transparent,
animationSpec = infiniteRepeatable(
animationSpec = repeatable(
iterations = 6,
animation = tween(
durationMillis = BLINK_DURATION,
durationMillis = 250,
easing = FastOutSlowInEasing
),
repeatMode = RepeatMode.Reverse
)
), label = "blinking node"
)
Card(
@ -117,9 +113,10 @@ fun NodeInfo(
content = {
Text(
modifier = Modifier.fillMaxWidth(),
text = (thatNodeInfo.user?.shortName ?: unknownShortName).strikeIf(isIgnored),
text = thatNodeInfo.user?.shortName ?: unknownShortName,
fontWeight = FontWeight.Normal,
fontSize = MaterialTheme.typography.button.fontSize,
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
textAlign = TextAlign.Center,
)
},
@ -156,8 +153,9 @@ fun NodeInfo(
)
width = Dimension.preferredWrapContent
},
text = nodeName.strikeIf(isIgnored),
style = style
text = nodeName,
style = style,
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
)
val position = thatNodeInfo.position
@ -274,18 +272,6 @@ fun NodeInfo(
}
}
private fun String.strike() = AnnotatedString(
this,
spanStyles = listOf(
AnnotatedString.Range(
SpanStyle(textDecoration = TextDecoration.LineThrough),
start = 0,
end = this.length
)
)
)
private fun String.strikeIf(isIgnored: Boolean): AnnotatedString = if (isIgnored) strike() else AnnotatedString(this)
@Composable
@Preview(showBackground = false)
fun NodeInfoSimplePreview() {
@ -321,4 +307,4 @@ fun NodeInfoPreview(
true
)
}
}
}

View file

@ -1,392 +1,205 @@
package com.geeksville.mesh.ui
import android.content.Context
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.asLiveData
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import com.geeksville.mesh.NodeInfo
import com.geeksville.mesh.R
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.databinding.NodelistFragmentBinding
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.ui.components.NodeFilterTextField
import com.geeksville.mesh.ui.theme.AppTheme
import com.geeksville.mesh.util.Exceptions
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
/**
* Workaround for RecyclerView bug throwing:
* java.lang.IndexOutOfBoundsException - Inconsistency detected. Invalid view holder adapter
*/
private class LinearLayoutManagerWrapper(context: Context) : LinearLayoutManager(context) {
override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) {
try {
super.onLayoutChildren(recycler, state)
} catch (ex: IndexOutOfBoundsException) {
Exceptions.report(ex, "onLayoutChildren")
}
}
}
@AndroidEntryPoint
class UsersFragment : ScreenFragment("Users"), Logging {
private var _binding: NodelistFragmentBinding? = null
// This property is only valid between onCreateView and onDestroyView.
private val binding get() = _binding!!
private val model: UIViewModel by activityViewModels()
private val ignoreIncomingList: MutableList<Int> = mutableListOf()
private var gpsFormat = 0
private var displayUnits = 0
private var displayFahrenheit = false
class ViewHolder(val composeView: ComposeView) : RecyclerView.ViewHolder(composeView) {
var shouldBlink by mutableStateOf(false)
suspend fun blink() {
shouldBlink = true
delay(500)
shouldBlink = false
private fun popup(view: View, node: NodeInfo) {
if (!model.isConnected()) return
val user = node.user ?: return
val isOurNode = node.num == model.myNodeNum
val showAdmin = isOurNode || model.hasAdminChannel
val ignoreIncomingList = model.ignoreIncomingList
val isIgnored = ignoreIncomingList.contains(node.num)
val popup =
PopupMenu(view.context, view, Gravity.NO_GRAVITY, R.attr.actionOverflowMenuStyle, 0)
popup.inflate(R.menu.menu_nodes)
popup.menu.setGroupVisible(R.id.group_remote, !isOurNode)
popup.menu.setGroupVisible(R.id.group_admin, showAdmin)
popup.menu.setGroupEnabled(R.id.group_admin, !model.isManaged)
popup.menu.findItem(R.id.ignore).apply {
isEnabled = isIgnored || ignoreIncomingList.size < 3
isChecked = isIgnored
}
fun bind(
thisNodeInfo: NodeInfo?,
thatNodeInfo: NodeInfo,
gpsFormat: Int,
distanceUnits: Int,
tempInFahrenheit: Boolean,
onChipClicked: () -> Unit
) {
composeView.setContent {
AppTheme {
NodeInfo(
thisNodeInfo = thisNodeInfo,
thatNodeInfo = thatNodeInfo,
gpsFormat = gpsFormat,
distanceUnits = distanceUnits,
tempInFahrenheit = tempInFahrenheit,
onClicked = onChipClicked,
blinking = shouldBlink,
)
popup.setOnMenuItemClickListener { item: MenuItem ->
when (item.itemId) {
R.id.direct_message -> {
val contactKey = "${node.channel}${user.id}"
debug("calling MessagesFragment filter: $contactKey")
parentFragmentManager.navigateToMessages(contactKey, user.longName)
}
}
}
}
private val nodesAdapter = object : RecyclerView.Adapter<ViewHolder>() {
R.id.request_position -> {
debug("requesting position for '${user.longName}'")
model.requestPosition(node.num)
}
var nodes = arrayOf<NodeInfo>()
private set
R.id.traceroute -> {
debug("requesting traceroute for '${user.longName}'")
model.requestTraceroute(node.num)
}
private fun popup(view: View, node: NodeInfo) {
if (!model.isConnected()) return
val user = node.user ?: return
val isOurNode = node.num == model.myNodeNum
val showAdmin = isOurNode || model.hasAdminChannel
val isIgnored = ignoreIncomingList.contains(node.num)
val popup = PopupMenu(requireContext(), view)
popup.inflate(R.menu.menu_nodes)
popup.menu.setGroupVisible(R.id.group_remote, !isOurNode)
popup.menu.setGroupVisible(R.id.group_admin, showAdmin)
popup.menu.setGroupEnabled(R.id.group_admin, !model.isManaged)
popup.menu.findItem(R.id.ignore).apply {
isEnabled = isIgnored || ignoreIncomingList.size < 3
isChecked = isIgnored
}
popup.setOnMenuItemClickListener { item: MenuItem ->
when (item.itemId) {
R.id.direct_message -> {
val contactKey = "${node.channel}${user.id}"
debug("calling MessagesFragment filter: $contactKey")
parentFragmentManager.navigateToMessages(contactKey, user.longName)
}
R.id.request_position -> {
debug("requesting position for '${user.longName}'")
model.requestPosition(node.num)
}
R.id.traceroute -> {
debug("requesting traceroute for '${user.longName}'")
model.requestTraceroute(node.num)
}
R.id.remove -> {
R.id.remove -> {
MaterialAlertDialogBuilder(view.context)
.setTitle(R.string.remove)
.setMessage(getString(R.string.remove_node_text))
.setNeutralButton(R.string.cancel) { _, _ -> }
.setPositiveButton(R.string.send) { _, _ ->
debug("removing node '${user.longName}'")
model.removeNode(node.num)
}
.show()
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.remove)
.setMessage(getString(R.string.remove_node_text))
.setNeutralButton(R.string.cancel) { _, _ -> }
.setPositiveButton(R.string.send) {_,_ ->
debug("removing node '${user.longName}'")
model.removeNode(node.num)
}
.show()
}
R.id.ignore -> {
val message = if (isIgnored) R.string.ignore_remove else R.string.ignore_add
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ignore)
.setMessage(getString(message, user.longName))
.setNeutralButton(R.string.cancel) { _, _ -> }
.setPositiveButton(R.string.send) { _, _ ->
model.ignoreIncomingList = ignoreIncomingList.apply {
if (isIgnored) {
debug("removed '${user.longName}' from ignore list")
remove(node.num)
} else {
debug("added '${user.longName}' to ignore list")
add(node.num)
}
R.id.ignore -> {
val message = if (isIgnored) R.string.ignore_remove else R.string.ignore_add
MaterialAlertDialogBuilder(view.context)
.setTitle(R.string.ignore)
.setMessage(getString(message, user.longName))
.setNeutralButton(R.string.cancel) { _, _ -> }
.setPositiveButton(R.string.send) { _, _ ->
model.ignoreIncomingList = ignoreIncomingList.toMutableList().apply {
if (isIgnored) {
debug("removed '${user.longName}' from ignore list")
remove(node.num)
} else {
debug("added '${user.longName}' to ignore list")
add(node.num)
}
item.isChecked = !item.isChecked
notifyItemChanged(nodes.indexOfFirst { it.num == node.num })
}
.show()
}
R.id.remote_admin -> {
debug("calling remote admin --> destNum: ${node.num.toUInt()}")
setFragmentResult("requestKey", bundleOf("destNum" to node.num))
parentFragmentManager.beginTransaction()
.replace(R.id.mainActivityLayout, DeviceSettingsFragment())
.addToBackStack(null)
.commit()
}
item.isChecked = !item.isChecked
}
.show()
}
true
}
popup.show()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(ComposeView(parent.context))
}
override fun getItemCount(): Int = nodes.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val thisNode = model.ourNodeInfo.value
val thatNode = nodes[position]
holder.bind(
thisNodeInfo = thisNode,
thatNodeInfo = thatNode,
gpsFormat = gpsFormat,
distanceUnits = displayUnits,
tempInFahrenheit = displayFahrenheit
) {
popup(holder.composeView, thatNode)
}
}
override fun onViewRecycled(holder: ViewHolder) {
holder.composeView.disposeComposition()
}
// Called when our node DB changes
fun onNodesChanged(nodesIn: Array<NodeInfo>) {
if (nodesIn.isEmpty()) {
notifyItemRangeRemoved(0, nodes.size)
nodes = emptyArray()
return
}
val previousNodes = nodes
if (nodesIn.size < previousNodes.size) {
notifyItemRangeRemoved(nodesIn.size, previousNodes.size - nodesIn.size)
}
val indexChanged = nodesIn.mapIndexed { index, nodeInfo ->
previousNodes.getOrNull(index) != nodeInfo
}
if (indexChanged.isEmpty()) return
nodes = nodesIn
for (i in indexChanged.indices) {
if (indexChanged[i]) {
notifyItemChanged(i)
R.id.remote_admin -> {
debug("calling remote admin --> destNum: ${node.num.toUInt()}")
parentFragmentManager.navigateToRadioConfig(node.num)
}
}
true
}
popup.show()
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = NodelistFragmentBinding.inflate(inflater, container, false)
return binding.root
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppTheme {
NodesScreen(model = model, onClick = { popup(requireView(), it) })
}
}
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NodesScreen(
model: UIViewModel = hiltViewModel(),
onClick: (NodeInfo) -> Unit,
) {
val state by model.nodesUiState.collectAsStateWithLifecycle()
binding.nodeListView.adapter = nodesAdapter
binding.nodeListView.layoutManager = LinearLayoutManagerWrapper(requireContext())
val nodes by model.nodeList.collectAsStateWithLifecycle()
val ourNodeInfo by model.ourNodeInfo.collectAsStateWithLifecycle()
binding.nodeFilter.initFilter()
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
model.nodeList.asLiveData().observe(viewLifecycleOwner) { nodeMap ->
nodesAdapter.onNodesChanged(nodeMap.toTypedArray())
}
model.localConfig.asLiveData().observe(viewLifecycleOwner) { config ->
ignoreIncomingList.apply {
clear()
addAll(config.lora.ignoreIncomingList)
}
gpsFormat = config.display.gpsFormat.number
displayUnits = config.display.units.number
}
model.moduleConfig.asLiveData().observe(viewLifecycleOwner) { module ->
displayFahrenheit = module.telemetry.environmentDisplayFahrenheit
}
model.tracerouteResponse.observe(viewLifecycleOwner) { response ->
MaterialAlertDialogBuilder(requireContext())
.setCancelable(false)
.setTitle(R.string.traceroute)
.setMessage(response ?: return@observe)
.setPositiveButton(R.string.okay) { _, _ -> }
.show()
model.clearTracerouteResponse()
}
model.focusedNode.asLiveData().observe(viewLifecycleOwner) { node ->
val idx = nodesAdapter.nodes.indexOfFirst {
it.user?.id == node?.user?.id
}
if (idx < 1) return@observe
lifecycleScope.launch {
with (binding.nodeListView.layoutManager as LinearLayoutManager) {
smoothScrollToTop(idx)
binding.nodeListView.awaitScrollStateIdle()
if (!isIndexAtTop(idx)) { // settle the scroll position
smoothScrollToTop(idx)
}
val vh = binding.nodeListView.findViewHolderForLayoutPosition(idx)
(vh as? ViewHolder)?.blink() ?: warn("viewholder wasn't there. May need to wait for it")
val focusedNode by model.focusedNode.collectAsStateWithLifecycle()
LaunchedEffect(focusedNode) {
focusedNode?.let { node ->
val index = nodes.indexOfFirst { it == node }
if (index != -1) {
coroutineScope.launch {
listState.animateScrollToItem(index)
model.focusUserNode(null)
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
/**
* Scrolls the recycler view until the item at [position] is at the top of the view, then waits
* until the scrolling is finished.
* @param precision The time in milliseconds to wait between checks for the scroll state.
*/
private suspend fun RecyclerView.LayoutManager.smoothScrollToTop(
position: Int, precision: Long = 100
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
) {
this.startSmoothScroll(
object : LinearSmoothScroller(requireContext()) {
override fun getVerticalSnapPreference(): Int {
return SNAP_TO_START
}
}.apply {
targetPosition = position
}
)
while (isSmoothScrolling) {
delay(precision)
}
}
private fun ComposeView.initFilter() {
this.setContent {
val nodeViewState by model.nodesUiState.collectAsStateWithLifecycle()
AppTheme {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
) {
NodeFilterTextField(
filterText = nodeViewState.filter,
onTextChanged = model::setNodeFilterText,
modifier = Modifier.weight(1f)
)
NodeSortButton(
currentSortOption = nodeViewState.sort,
onSortSelected = model::setSortOption,
includeUnknown = nodeViewState.includeUnknown,
onToggleIncludeUnknown = model::toggleIncludeUnknown,
)
}
stickyHeader {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.background)
.padding(8.dp),
) {
NodeFilterTextField(
filterText = state.filter,
onTextChanged = model::setNodeFilterText,
modifier = Modifier.weight(1f)
)
NodeSortButton(
currentSortOption = state.sort,
onSortSelected = model::setSortOption,
includeUnknown = state.includeUnknown,
onToggleIncludeUnknown = model::toggleIncludeUnknown,
)
}
}
}
private suspend fun RecyclerView.awaitScrollStateIdle() = suspendCancellableCoroutine { continuation ->
if (scrollState == RecyclerView.SCROLL_STATE_IDLE) {
warn("RecyclerView scrollState is already idle")
continuation.resume(Unit)
return@suspendCancellableCoroutine
}
val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
recyclerView.removeOnScrollListener(this)
continuation.resume(Unit)
}
}
}
addOnScrollListener(scrollListener)
continuation.invokeOnCancellation {
removeOnScrollListener(scrollListener)
items(nodes, key = { it.num }) { node ->
NodeInfo(
thisNodeInfo = ourNodeInfo,
thatNodeInfo = node,
gpsFormat = state.gpsFormat,
distanceUnits = state.distanceUnits,
tempInFahrenheit = state.tempInFahrenheit,
isIgnored = state.ignoreIncomingList.contains(node.num),
onClicked = { onClick(node) },
blinking = node == focusedNode,
)
}
}
fun LinearLayoutManager.isIndexAtTop(idx: Int): Boolean {
val first = findFirstVisibleItemPosition()
val firstVisible = findFirstCompletelyVisibleItemPosition()
return first == idx && firstVisible == idx
}
}

View file

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/nodeCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
tools:composableName="com.geeksville.mesh.ui.NodeInfoKt.NodeInfoSimplePreview"
/>
</LinearLayout>

View file

@ -1,55 +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:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="16dp">
<EditText
android:id="@+id/waypointName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:hint="@string/name"
android:inputType="textShortMessage"
android:minHeight="48dp" />
<EditText
android:id="@+id/waypointDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:hint="@string/description"
android:inputType="textMultiLine"
android:minHeight="48dp" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/imageLock"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/sl_lock_24dp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/waypointLocked"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:text="@string/locked"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageLock"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

View file

@ -1,34 +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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="@string/a_list_of_nodes_in_the_mesh"
>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/nodeFilter"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginTop="8dp"
android:elevation="8dp"
tools:layout_height="48dp"
tools:composableName="com.geeksville.mesh.ui.components.NodeFilterTextFieldKt.NodeFilterTextFieldPreview"
/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/nodeListView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:contentDescription="@string/list_of_nodes"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/nodeFilter"
app:layout_constraintBottom_toBottomOf="parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>