mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: migrate UsersFragment to Compose
This commit is contained in:
parent
098c89f45c
commit
db500c5200
8 changed files with 184 additions and 474 deletions
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue