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 cdc6bfe21..2f5b69b50 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeInfo.kt @@ -1,5 +1,11 @@ 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.tween +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth @@ -15,6 +21,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text 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.res.stringResource @@ -47,8 +54,12 @@ fun NodeInfo( distanceUnits: Int, tempInFahrenheit: Boolean, isIgnored: Boolean = false, - onClicked: () -> Unit = {} + 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) @@ -57,6 +68,18 @@ fun NodeInfo( val distance = thisNodeInfo?.distanceStr(thatNodeInfo, distanceUnits) val (textColor, nodeColor) = thatNodeInfo.colors + val highlight = Color(0x33FFFFFF) + val bgColor by animateColorAsState( + targetValue = if (blinking) highlight else Color.Transparent, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = BLINK_DURATION, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ) + ) + Card( modifier = Modifier .fillMaxWidth() @@ -67,6 +90,7 @@ fun NodeInfo( ConstraintLayout( modifier = Modifier .fillMaxWidth() + .background(bgColor) .padding(8.dp) ) { val (chip, dist, name, pos, alt, sats, batt, heard, sig, env) = createRefs() 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 5f0f953b0..a2b43c04f 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -1,25 +1,23 @@ package com.geeksville.mesh.ui -import android.animation.ValueAnimator import android.content.Context -import android.content.res.ColorStateList import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.view.animation.LinearInterpolator import androidx.appcompat.widget.PopupMenu import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.unit.dp -import androidx.core.animation.doOnEnd import androidx.fragment.app.activityViewModels import androidx.lifecycle.asLiveData import androidx.lifecycle.lifecycleScope @@ -36,9 +34,10 @@ 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.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume /** * Workaround for RecyclerView bug throwing: @@ -71,26 +70,12 @@ class UsersFragment : ScreenFragment("Users"), Logging { class ViewHolder(val composeView: ComposeView) : RecyclerView.ViewHolder(composeView) { - // TODO not working with compose changes - fun blink() { - val bg = composeView.backgroundTintList - ValueAnimator.ofArgb( - android.graphics.Color.parseColor("#00FFFFFF"), - android.graphics.Color.parseColor("#33FFFFFF") - ).apply { - interpolator = LinearInterpolator() - startDelay = 500 - duration = 250 - repeatCount = 3 - repeatMode = ValueAnimator.REVERSE - addUpdateListener { - composeView.backgroundTintList = ColorStateList.valueOf(it.animatedValue as Int) - } - start() - doOnEnd { - composeView.backgroundTintList = bg - } - } + var shouldBlink by mutableStateOf(false) + + suspend fun blink() { + shouldBlink = true + delay(500) + shouldBlink = false } fun bind( @@ -109,7 +94,8 @@ class UsersFragment : ScreenFragment("Users"), Logging { gpsFormat = gpsFormat, distanceUnits = distanceUnits, tempInFahrenheit = tempInFahrenheit, - onClicked = onChipClicked + onClicked = onChipClicked, + blinking = shouldBlink, ) } } @@ -295,10 +281,18 @@ class UsersFragment : ScreenFragment("Users"), Logging { if (idx < 1) return@observe lifecycleScope.launch { - binding.nodeListView.layoutManager?.smoothScrollToTop(idx) - val vh = binding.nodeListView.findViewHolderForLayoutPosition(idx) - (vh as? ViewHolder)?.blink() - model.focusUserNode(null) + 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") + model.focusUserNode(null) + } } } } @@ -311,8 +305,11 @@ class UsersFragment : ScreenFragment("Users"), Logging { /** * 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) { + private suspend fun RecyclerView.LayoutManager.smoothScrollToTop( + position: Int, precision: Long = 100 + ) { this.startSmoothScroll( object : LinearSmoothScroller(requireContext()) { override fun getVerticalSnapPreference(): Int { @@ -322,10 +319,9 @@ class UsersFragment : ScreenFragment("Users"), Logging { targetPosition = position } ) - withContext(Dispatchers.Default) { - while (this@smoothScrollToTop.isSmoothScrolling) { - // noop - } + + while (isSmoothScrolling) { + delay(precision) } } @@ -348,4 +344,31 @@ class UsersFragment : ScreenFragment("Users"), Logging { } } + 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) + } + } + + fun LinearLayoutManager.isIndexAtTop(idx: Int): Boolean { + val first = findFirstVisibleItemPosition() + val firstVisible = findFirstCompletelyVisibleItemPosition() + return first == idx && firstVisible == idx + } + } diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt index b0247f78e..e6fd0ba85 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt @@ -105,21 +105,12 @@ class MapFragment : ScreenFragment("Map Fragment"), Logging { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { AppCompatTheme { - MapView(model, ::openDirectMessage) + MapView(model) } } } } - private fun openDirectMessage(node: NodeInfo) { - val user = node.user ?: return - model.setContactKey("${node.channel}${user.id}") - parentFragmentManager.beginTransaction() - .replace(R.id.mainActivityLayout, MessagesFragment()) - .addToBackStack(null) - .commit() - } - } @Composable @@ -135,7 +126,6 @@ private fun MapView.UpdateMarkers( @Composable fun MapView( model: UIViewModel = viewModel(), - openDirectMessage: (NodeInfo) -> Unit = { }, ) { // UI Elements @@ -236,7 +226,8 @@ fun MapView( icon = markerIcon setOnLongClickListener { - openDirectMessage(node) + performHapticFeedback() + model.focusUserNode(node) true } }