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 18cfea79d..da04eecd4 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -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 = radioInterfaceService.meshActivity.shareIn(viewModelScope, SharingStarted.Eagerly, 0) + private val scrollToTopEventChannel = Channel(capacity = Channel.CONFLATED) + val scrollToTopEventFlow: Flow = scrollToTopEventChannel.receiveAsFlow() + + fun emitScrollToTopEvent(event: ScrollToTopEvent) { + scrollToTopEventChannel.trySend(event) + } + data class AlertData( val title: String, val message: String? = null, diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt index baee9ecf1..fad262b75 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/ContactsNavigation.kt @@ -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) { navigation(startDestination = ContactsRoutes.Contacts) { composable( deepLinks = listOf(navDeepLink(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( diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt index d40439b82..2adb4e103 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt @@ -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) { navigation(startDestination = NodesRoutes.Nodes) { composable( deepLinks = listOf(navDeepLink(basePath = "$DEEP_LINK_BASE_URI/nodes")), ) { - NodeListScreen(navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) }) + NodeListScreen( + navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) }, + scrollToTopEvents = scrollToTopEvents, + ) } nodeDetailGraph(navController) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 62dba6474..1ad2aa61a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -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) diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt index 52079d58d..8fc4a5b6b 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt @@ -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? = 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, 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) } } diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollToTopEvent.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollToTopEvent.kt new file mode 100644 index 000000000..7f2880fd2 --- /dev/null +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollToTopEvent.kt @@ -0,0 +1,28 @@ +/* + * 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.core.ui.component + +/** + * Event emitted when a user re-presses a bottom navigation destination that should trigger a scroll-to-top behaviour on + * the corresponding screen. + */ +sealed class ScrollToTopEvent { + data object NodesTabPressed : ScrollToTopEvent() + + data object ConversationsTabPressed : ScrollToTopEvent() +} diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollToTopExtensions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollToTopExtensions.kt new file mode 100644 index 000000000..6eb4ac1f1 --- /dev/null +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollToTopExtensions.kt @@ -0,0 +1,46 @@ +/* + * 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.core.ui.component + +import androidx.compose.foundation.lazy.LazyListState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.math.max + +private const val SCROLL_TO_TOP_INDEX = 0 +private const val FAST_SCROLL_THRESHOLD = 10 + +/** + * Executes the smart scroll-to-top policy. + * + * Policy: + * - If the first visible item is already at index 0, do nothing. + * - Otherwise, smoothly animate the list back to the first item. + */ +fun LazyListState.smartScrollToTop(coroutineScope: CoroutineScope) { + if (firstVisibleItemIndex == SCROLL_TO_TOP_INDEX) { + return + } + coroutineScope.launch { + if (firstVisibleItemIndex > FAST_SCROLL_THRESHOLD) { + val jumpIndex = max(SCROLL_TO_TOP_INDEX, firstVisibleItemIndex - FAST_SCROLL_THRESHOLD) + scrollToItem(jumpIndex) + } + animateScrollToItem(index = SCROLL_TO_TOP_INDEX) + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index 3805a5731..99c0335fa 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -44,10 +44,12 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text 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.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 @@ -56,6 +58,8 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DeviceVersion @@ -70,7 +74,9 @@ import org.meshtastic.core.strings.remove_favorite import org.meshtastic.core.strings.remove_ignored import org.meshtastic.core.ui.component.AddContactFAB import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.core.ui.component.rememberTimeTickWithLifecycle +import org.meshtastic.core.ui.component.smartScrollToTop import org.meshtastic.core.ui.component.supportsQrCodeSharing import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.feature.node.component.NodeActionDialogs @@ -81,7 +87,11 @@ import org.meshtastic.proto.AdminProtos @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable -fun NodeListScreen(viewModel: NodeListViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit) { +fun NodeListScreen( + navigateToNodeDetails: (Int) -> Unit, + viewModel: NodeListViewModel = hiltViewModel(), + scrollToTopEvents: Flow? = null, +) { val state by viewModel.nodesUiState.collectAsStateWithLifecycle() val nodes by viewModel.nodeList.collectAsStateWithLifecycle() @@ -92,6 +102,15 @@ fun NodeListScreen(viewModel: NodeListViewModel = hiltViewModel(), navigateToNod val ignoredNodeCount = unfilteredNodes.count { it.isIgnored } val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(scrollToTopEvents) { + scrollToTopEvents?.collectLatest { event -> + if (event is ScrollToTopEvent.NodesTabPressed) { + listState.smartScrollToTop(coroutineScope) + } + } + } val currentTimeMillis = rememberTimeTickWithLifecycle() val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()