From db500c520025811f270fd067fecc854a553e9b2c Mon Sep 17 00:00:00 2001 From: andrekir Date: Sun, 23 Jun 2024 08:24:29 -0300 Subject: [PATCH] refactor: migrate `UsersFragment` to Compose --- .../java/com/geeksville/mesh/MainActivity.kt | 23 +- .../java/com/geeksville/mesh/model/UIState.kt | 12 +- .../mesh/ui/DeviceSettingsFragment.kt | 19 +- .../java/com/geeksville/mesh/ui/NodeInfo.kt | 36 +- .../com/geeksville/mesh/ui/UsersFragment.kt | 461 ++++++------------ .../main/res/layout/adapter_node_layout.xml | 18 - .../main/res/layout/dialog_add_waypoint.xml | 55 --- app/src/main/res/layout/nodelist_fragment.xml | 34 -- 8 files changed, 184 insertions(+), 474 deletions(-) delete mode 100644 app/src/main/res/layout/adapter_node_layout.xml delete mode 100644 app/src/main/res/layout/dialog_add_waypoint.xml delete mode 100644 app/src/main/res/layout/nodelist_fragment.xml diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 61ad9347a..d889a7b6e 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -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 -> { diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 59b1e7699..1807b33ed 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -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 = 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 } - } diff --git a/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt index 6146363ed..658dc8a49 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt @@ -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) diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeInfo.kt index 88a02b7d3..da57200d3 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeInfo.kt @@ -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 ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt index 423511947..2c6f9db10 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -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 = 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() { + R.id.request_position -> { + debug("requesting position for '${user.longName}'") + model.requestPosition(node.num) + } - var nodes = arrayOf() - 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) { - 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 - } - } diff --git a/app/src/main/res/layout/adapter_node_layout.xml b/app/src/main/res/layout/adapter_node_layout.xml deleted file mode 100644 index 96216953c..000000000 --- a/app/src/main/res/layout/adapter_node_layout.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_add_waypoint.xml b/app/src/main/res/layout/dialog_add_waypoint.xml deleted file mode 100644 index ec69e334e..000000000 --- a/app/src/main/res/layout/dialog_add_waypoint.xml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/nodelist_fragment.xml b/app/src/main/res/layout/nodelist_fragment.xml deleted file mode 100644 index 4b4ef1c10..000000000 --- a/app/src/main/res/layout/nodelist_fragment.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - \ No newline at end of file