From 411a8b5dbbda965cf01cbb6d22c1fa4819b0d268 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 18 Oct 2025 09:13:38 -0500 Subject: [PATCH] feat(map): Add pulsing animation to recently heard nodes (#3495) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../org/meshtastic/feature/map/MapView.kt | 4 +- .../map/component/NodeClusterMarkers.kt | 5 +- .../feature/map/component/PulsingNodeChip.kt | 70 +++++++++++++++++++ 3 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 feature/map/src/google/kotlin/org/meshtastic/feature/map/component/PulsingNodeChip.kt diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index 7c028d258..22b5444df 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -402,7 +402,7 @@ fun MapView( DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) } val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1)) - val color = Color(focusedNode!!.colors.second).copy(alpha = alpha) + val color = Color(focusedNode.colors.second).copy(alpha = alpha) if (index == sortedPositions.lastIndex) { MarkerComposable(state = markerState, zIndex = 1f) { NodeChip(node = focusedNode) } } else { @@ -428,7 +428,7 @@ fun MapView( } } - if (sortedPositions.size > 1 && focusedNode != null) { + if (sortedPositions.size > 1) { val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false) segments.forEachIndexed { index, segmentPoints -> val alpha = (index.toFloat() / (segments.size.toFloat() - 1)) diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt index 2640a204e..b1ad47f75 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/NodeClusterMarkers.kt @@ -25,7 +25,6 @@ import com.google.maps.android.clustering.view.DefaultClusterRenderer import com.google.maps.android.compose.Circle import com.google.maps.android.compose.MapsComposeExperimentalApi import com.google.maps.android.compose.clustering.Clustering -import org.meshtastic.core.ui.component.NodeChip import org.meshtastic.feature.map.BaseMapViewModel import org.meshtastic.feature.map.model.NodeClusterItem @@ -50,7 +49,7 @@ fun NodeClusterMarkers( fillColor = Color(clusterItem.node.colors.second).copy(alpha = 0.2f), strokeColor = Color(clusterItem.node.colors.second), strokeWidth = 2f, - zIndex = 1f, // Ensure circles are drawn above markers + zIndex = 0f, ) } } @@ -64,7 +63,7 @@ fun NodeClusterMarkers( navigateToNodeDetails(item.node.num) false }, - clusterItemContent = { clusterItem -> NodeChip(node = clusterItem.node) }, + clusterItemContent = { clusterItem -> PulsingNodeChip(node = clusterItem.node) }, onClusterManager = { clusterManager -> (clusterManager.renderer as DefaultClusterRenderer).minClusterSize = 10 }, diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/PulsingNodeChip.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/PulsingNodeChip.kt new file mode 100644 index 000000000..ad5ade0e6 --- /dev/null +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/PulsingNodeChip.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.feature.map.component + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.meshtastic.core.database.model.Node +import org.meshtastic.core.ui.component.NodeChip +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@Composable +fun PulsingNodeChip(node: Node, modifier: Modifier = Modifier) { + val animatedProgress = remember { Animatable(0f) } + + LaunchedEffect(node) { + if ((System.currentTimeMillis().milliseconds.inWholeSeconds - node.lastHeard.seconds.inWholeSeconds) <= 5) { + launch { + animatedProgress.snapTo(0f) + animatedProgress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 1000, easing = LinearEasing), + ) + } + } + } + + Box( + modifier = + modifier.drawWithContent { + drawContent() + if (animatedProgress.value > 0 && animatedProgress.value < 1f) { + val alpha = (1f - animatedProgress.value) * 0.3f + drawRoundRect( + size = size, + cornerRadius = CornerRadius(8.dp.toPx()), + color = Color.White.copy(alpha = alpha), + ) + } + }, + ) { + NodeChip(node = node) + } +}