feat: Add bottom-nav scroll-to-top handling for nodes and conversations (#3674)

This commit is contained in:
Mac DeCourcy 2025-11-12 14:22:21 -08:00 committed by GitHub
parent 00276bc5d4
commit bc8ff26167
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 176 additions and 13 deletions

View file

@ -28,6 +28,8 @@ import androidx.navigation.NavHostController
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
@ -38,6 +40,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.shareIn
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.analytics.platform.PlatformAnalytics
@ -54,6 +57,7 @@ import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.toSharedContact
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.AdminProtos
@ -127,6 +131,13 @@ constructor(
val meshActivity: SharedFlow<MeshActivity> =
radioInterfaceService.meshActivity.shareIn(viewModelScope, SharingStarted.Eagerly, 0)
private val scrollToTopEventChannel = Channel<ScrollToTopEvent>(capacity = Channel.CONFLATED)
val scrollToTopEventFlow: Flow<ScrollToTopEvent> = scrollToTopEventChannel.receiveAsFlow()
fun emitScrollToTopEvent(event: ScrollToTopEvent) {
scrollToTopEventChannel.trySend(event)
}
data class AlertData(
val title: String,
val message: String? = null,

View file

@ -25,15 +25,17 @@ import androidx.navigation.navigation
import androidx.navigation.toRoute
import com.geeksville.mesh.ui.contact.ContactsScreen
import com.geeksville.mesh.ui.sharing.ShareScreen
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.messaging.MessageScreen
import org.meshtastic.feature.messaging.QuickChatScreen
@Suppress("LongMethod")
fun NavGraphBuilder.contactsGraph(navController: NavHostController) {
fun NavGraphBuilder.contactsGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
navigation<ContactsRoutes.ContactsGraph>(startDestination = ContactsRoutes.Contacts) {
composable<ContactsRoutes.Contacts>(
deepLinks = listOf(navDeepLink<ContactsRoutes.Contacts>(basePath = "$DEEP_LINK_BASE_URI/contacts")),
@ -48,6 +50,7 @@ fun NavGraphBuilder.contactsGraph(navController: NavHostController) {
onNavigateToMessages = { navController.navigate(ContactsRoutes.Messages(it)) },
onNavigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
onNavigateToShare = { navController.navigate(ChannelsRoutes.ChannelsGraph) },
scrollToTopEvents = scrollToTopEvents,
)
}
composable<ContactsRoutes.Messages>(

View file

@ -38,6 +38,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.navDeepLink
import kotlinx.coroutines.flow.Flow
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
@ -54,6 +55,7 @@ import org.meshtastic.core.strings.position_log
import org.meshtastic.core.strings.power
import org.meshtastic.core.strings.signal
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.map.node.NodeMapScreen
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.feature.node.detail.NodeDetailScreen
@ -68,12 +70,15 @@ import org.meshtastic.feature.node.metrics.PowerMetricsScreen
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
fun NavGraphBuilder.nodesGraph(navController: NavHostController) {
fun NavGraphBuilder.nodesGraph(navController: NavHostController, scrollToTopEvents: Flow<ScrollToTopEvent>) {
navigation<NodesRoutes.NodesGraph>(startDestination = NodesRoutes.Nodes) {
composable<NodesRoutes.Nodes>(
deepLinks = listOf(navDeepLink<NodesRoutes.Nodes>(basePath = "$DEEP_LINK_BASE_URI/nodes")),
) {
NodeListScreen(navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) })
NodeListScreen(
navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) },
scrollToTopEvents = scrollToTopEvents,
)
}
nodeDetailGraph(navController)
}

View file

@ -128,6 +128,7 @@ import org.meshtastic.core.strings.should_update
import org.meshtastic.core.strings.should_update_firmware
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.ui.component.MultipleChoiceAlertDialog
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.icon.Conversations
import org.meshtastic.core.ui.icon.Map
@ -380,9 +381,37 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
}
},
onClick = {
navController.navigate(destination.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
val isRepress = destination == topLevelDestination
if (isRepress) {
when (destination) {
TopLevelDestination.Nodes -> {
val onNodesList = currentDestination?.hasRoute(NodesRoutes.Nodes::class) == true
if (!onNodesList) {
navController.navigate(destination.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
}
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
}
TopLevelDestination.Conversations -> {
val onConversationsList =
currentDestination?.hasRoute(ContactsRoutes.Contacts::class) == true
if (!onConversationsList) {
navController.navigate(destination.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
}
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
}
else -> Unit
}
} else {
navController.navigate(destination.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
}
}
},
)
@ -394,8 +423,8 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
startDestination = NodesRoutes.NodesGraph,
modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding().imePadding(),
) {
contactsGraph(navController)
nodesGraph(navController)
contactsGraph(navController, uIViewModel.scrollToTopEventFlow)
nodesGraph(navController, uIViewModel.scrollToTopEventFlow)
mapGraph(navController)
channelsGraph(navController)
connectionsGraph(navController)

View file

@ -23,7 +23,9 @@ 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.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.selectable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.VolumeMute
@ -45,11 +47,13 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -60,6 +64,8 @@ import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.model.Contact
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.ContactSettings
@ -84,6 +90,8 @@ import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.select_all
import org.meshtastic.core.strings.unmute
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.proto.AppOnlyProtos
import java.util.concurrent.TimeUnit
@ -91,11 +99,12 @@ import java.util.concurrent.TimeUnit
@Suppress("LongMethod")
@Composable
fun ContactsScreen(
onNavigateToShare: () -> Unit,
viewModel: ContactsViewModel = hiltViewModel(),
onClickNodeChip: (Int) -> Unit = {},
onNavigateToMessages: (String) -> Unit = {},
onNavigateToNodeDetails: (Int) -> Unit = {},
onNavigateToShare: () -> Unit,
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
) {
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
@ -109,6 +118,17 @@ fun ContactsScreen(
// State for contacts list
val contacts by viewModel.contactList.collectAsStateWithLifecycle()
val contactsListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(scrollToTopEvents) {
scrollToTopEvents?.collectLatest { event ->
if (event is ScrollToTopEvent.ConversationsTabPressed) {
contactsListState.smartScrollToTop(coroutineScope)
}
}
}
// Derived state for selected contacts and count
val selectedContacts =
remember(contacts, selectedContactKeys) { contacts.filter { it.contactKey in selectedContactKeys } }
@ -205,8 +225,9 @@ fun ContactsScreen(
selectedList = selectedContactKeys,
onClick = onContactClick,
onLongClick = onContactLongClick,
channels = channels,
onNodeChipClick = onNodeChipClick,
listState = contactsListState,
channels = channels,
)
}
}
@ -424,11 +445,12 @@ fun ContactListView(
selectedList: List<String>,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
channels: AppOnlyProtos.ChannelSet? = null,
onNodeChipClick: (Contact) -> Unit,
listState: LazyListState,
channels: AppOnlyProtos.ChannelSet? = null,
) {
val haptics = LocalHapticFeedback.current
LazyColumn(modifier = Modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.fillMaxSize(), state = listState) {
items(contacts, key = { it.contactKey }) { contact ->
val selected by remember { derivedStateOf { selectedList.contains(contact.contactKey) } }