mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-31 20:21:36 -04:00
feat: Add bottom-nav scroll-to-top handling for nodes and conversations (#3674)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) } }
|
||||
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<ScrollToTopEvent>? = 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()
|
||||
|
||||
Reference in New Issue
Block a user