mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Refactor map layer management and navigation infrastructure (#4921)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
b608a04ca4
commit
a005231d94
142 changed files with 5408 additions and 3090 deletions
|
|
@ -86,3 +86,66 @@ actual fun rememberOpenUrl(): (url: String) -> Unit {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("Wrapping")
|
||||
actual fun rememberSaveFileLauncher(
|
||||
onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
|
||||
): (defaultFilename: String, mimeType: String) -> Unit {
|
||||
val launcher =
|
||||
androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
onUriReceived(uri.toString().let { org.meshtastic.core.common.util.MeshtasticUri(it) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return remember(launcher) {
|
||||
{ defaultFilename, mimeType ->
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = mimeType
|
||||
putExtra(Intent.EXTRA_TITLE, defaultFilename)
|
||||
}
|
||||
launcher.launch(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit {
|
||||
val launcher =
|
||||
androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { permissions ->
|
||||
if (permissions.values.any { it }) {
|
||||
onGranted()
|
||||
} else {
|
||||
onDenied()
|
||||
}
|
||||
}
|
||||
return remember(launcher) {
|
||||
{
|
||||
launcher.launch(
|
||||
arrayOf(
|
||||
android.Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
android.Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberOpenLocationSettings(): () -> Unit {
|
||||
val launcher =
|
||||
androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(),
|
||||
) { _ ->
|
||||
}
|
||||
return remember(launcher) { { launcher.launch(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)) } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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.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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.MeshActivity
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusBlue
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
|
||||
/**
|
||||
* A wrapper around [ConnectionsNavIcon] that adds a blinking glow effect when there is mesh activity (Send/Receive).
|
||||
*/
|
||||
@Composable
|
||||
fun AnimatedConnectionsNavIcon(
|
||||
connectionState: ConnectionState,
|
||||
deviceType: DeviceType?,
|
||||
meshActivityFlow: Flow<MeshActivity>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colorScheme = androidx.compose.material3.MaterialTheme.colorScheme
|
||||
var currentGlowColor by remember { mutableStateOf(Color.Transparent) }
|
||||
val animatedGlowAlpha = remember { Animatable(0f) }
|
||||
|
||||
val sendColor = colorScheme.StatusGreen
|
||||
val receiveColor = colorScheme.StatusBlue
|
||||
|
||||
LaunchedEffect(meshActivityFlow, colorScheme) {
|
||||
meshActivityFlow.conflate().collect { activity ->
|
||||
val newTargetColor =
|
||||
when (activity) {
|
||||
is MeshActivity.Send -> sendColor
|
||||
is MeshActivity.Receive -> receiveColor
|
||||
}
|
||||
|
||||
currentGlowColor = newTargetColor
|
||||
|
||||
// Suspend the collection until the animation finishes.
|
||||
// conflate() will drop any fast events that arrive during this 1-second animation.
|
||||
animatedGlowAlpha.stop()
|
||||
animatedGlowAlpha.snapTo(1.0f)
|
||||
animatedGlowAlpha.animateTo(
|
||||
targetValue = 0.0f,
|
||||
animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier.drawWithCache {
|
||||
val glowRadius = size.minDimension
|
||||
val glowBrush =
|
||||
Brush.radialGradient(
|
||||
colors =
|
||||
listOf(
|
||||
currentGlowColor.copy(alpha = 0.8f),
|
||||
currentGlowColor.copy(alpha = 0.4f),
|
||||
Color.Transparent,
|
||||
),
|
||||
center = Offset(size.width / 2, size.height / 2),
|
||||
radius = glowRadius,
|
||||
)
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
val alpha = animatedGlowAlpha.value
|
||||
if (alpha > 0f) {
|
||||
drawCircle(brush = glowBrush, radius = glowRadius, alpha = alpha, blendMode = BlendMode.Screen)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
ConnectionsNavIcon(connectionState = connectionState, deviceType = deviceType)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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.animation.Crossfade
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Bluetooth
|
||||
import androidx.compose.material.icons.rounded.Cached
|
||||
import androidx.compose.material.icons.rounded.Snooze
|
||||
import androidx.compose.material.icons.rounded.Usb
|
||||
import androidx.compose.material.icons.rounded.Wifi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.ui.icon.Device
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.NoDevice
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||
|
||||
@Composable
|
||||
fun ConnectionsNavIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
connectionState: ConnectionState,
|
||||
deviceType: DeviceType?,
|
||||
contentDescription: String? = null,
|
||||
) {
|
||||
val tint = getTint(connectionState)
|
||||
|
||||
val (backgroundIcon, connectionTypeIcon) = getIconPair(deviceType = deviceType, connectionState = connectionState)
|
||||
|
||||
val foregroundPainter = connectionTypeIcon?.let { rememberVectorPainter(it) }
|
||||
|
||||
Crossfade(targetState = backgroundIcon, label = "ConnectionIcon") {
|
||||
Icon(
|
||||
imageVector = it,
|
||||
contentDescription = contentDescription,
|
||||
tint = tint,
|
||||
modifier =
|
||||
modifier.drawWithContent {
|
||||
drawContent()
|
||||
foregroundPainter?.let {
|
||||
@Suppress("MagicNumber")
|
||||
val badgeSize = size.width * .45f
|
||||
with(it) { draw(Size(badgeSize, badgeSize), colorFilter = ColorFilter.tint(tint)) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getTint(connectionState: ConnectionState): Color = when (connectionState) {
|
||||
ConnectionState.Connecting -> colorScheme.StatusOrange
|
||||
ConnectionState.Disconnected -> colorScheme.StatusRed
|
||||
ConnectionState.DeviceSleep -> colorScheme.StatusYellow
|
||||
else -> colorScheme.StatusGreen
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getIconPair(connectionState: ConnectionState, deviceType: DeviceType? = null): Pair<ImageVector, ImageVector?> =
|
||||
when (connectionState) {
|
||||
ConnectionState.Disconnected -> MeshtasticIcons.NoDevice to null
|
||||
ConnectionState.DeviceSleep -> MeshtasticIcons.Device to Icons.Rounded.Snooze
|
||||
ConnectionState.Connecting -> MeshtasticIcons.Device to Icons.Rounded.Cached
|
||||
else ->
|
||||
MeshtasticIcons.Device to
|
||||
when (deviceType) {
|
||||
DeviceType.BLE -> Icons.Rounded.Bluetooth
|
||||
DeviceType.TCP -> Icons.Rounded.Wifi
|
||||
DeviceType.USB -> Icons.Rounded.Usb
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
@ -23,8 +23,6 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.navigation.DeepLinkRouter
|
||||
import org.meshtastic.core.navigation.NodeDetailRoutes
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
|
||||
|
|
@ -42,12 +40,9 @@ fun MeshtasticAppShell(
|
|||
content: @Composable () -> Unit,
|
||||
) {
|
||||
LaunchedEffect(uiViewModel) {
|
||||
uiViewModel.navigationDeepLink.collect { uri ->
|
||||
val commonUri = CommonUri.parse(uri.uriString)
|
||||
DeepLinkRouter.route(commonUri)?.let { navKeys ->
|
||||
backStack.clear()
|
||||
backStack.addAll(navKeys)
|
||||
}
|
||||
uiViewModel.navigationDeepLink.collect { navKeys ->
|
||||
backStack.clear()
|
||||
backStack.addAll(navKeys)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,293 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.window.core.layout.WindowWidthSizeClass
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.navigation.ContactsRoutes
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.core.navigation.TopLevelDestination
|
||||
import org.meshtastic.core.navigation.navigateTopLevel
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.connected
|
||||
import org.meshtastic.core.resources.connecting
|
||||
import org.meshtastic.core.resources.device_sleeping
|
||||
import org.meshtastic.core.resources.disconnected
|
||||
import org.meshtastic.core.ui.navigation.icon
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
|
||||
/**
|
||||
* Shared adaptive navigation shell. Provides a Bottom Navigation bar on phones, and a Navigation Rail on tablets and
|
||||
* desktop targets.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MeshtasticNavigationSuite(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
uiViewModel: UIViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val connectionState by uiViewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val unreadMessageCount by uiViewModel.unreadMessageCount.collectAsStateWithLifecycle()
|
||||
val selectedDevice by uiViewModel.currentDeviceAddressFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)
|
||||
val isCompact = adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
|
||||
val currentKey = backStack.lastOrNull()
|
||||
val rootKey = backStack.firstOrNull()
|
||||
val topLevelDestination = TopLevelDestination.fromNavKey(rootKey)
|
||||
|
||||
val onNavigate = { destination: TopLevelDestination ->
|
||||
handleNavigation(destination, topLevelDestination, currentKey, backStack, uiViewModel)
|
||||
}
|
||||
|
||||
if (isCompact) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
bottomBar = {
|
||||
MeshtasticNavigationBar(
|
||||
topLevelDestination = topLevelDestination,
|
||||
connectionState = connectionState,
|
||||
unreadMessageCount = unreadMessageCount,
|
||||
selectedDevice = selectedDevice,
|
||||
uiViewModel = uiViewModel,
|
||||
onNavigate = onNavigate,
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(padding)) { content() }
|
||||
}
|
||||
} else {
|
||||
Row(modifier = modifier.fillMaxSize()) {
|
||||
MeshtasticNavigationRail(
|
||||
topLevelDestination = topLevelDestination,
|
||||
connectionState = connectionState,
|
||||
unreadMessageCount = unreadMessageCount,
|
||||
selectedDevice = selectedDevice,
|
||||
uiViewModel = uiViewModel,
|
||||
onNavigate = onNavigate,
|
||||
)
|
||||
Box(modifier = Modifier.weight(1f).fillMaxSize()) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNavigation(
|
||||
destination: TopLevelDestination,
|
||||
topLevelDestination: TopLevelDestination?,
|
||||
currentKey: NavKey?,
|
||||
backStack: NavBackStack<NavKey>,
|
||||
uiViewModel: UIViewModel,
|
||||
) {
|
||||
val isRepress = destination == topLevelDestination
|
||||
if (isRepress) {
|
||||
when (destination) {
|
||||
TopLevelDestination.Nodes -> {
|
||||
val onNodesList = currentKey is NodesRoutes.NodesGraph || currentKey is NodesRoutes.Nodes
|
||||
if (!onNodesList) {
|
||||
backStack.navigateTopLevel(destination.route)
|
||||
} else {
|
||||
uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
|
||||
}
|
||||
}
|
||||
TopLevelDestination.Conversations -> {
|
||||
val onConversationsList =
|
||||
currentKey is ContactsRoutes.ContactsGraph || currentKey is ContactsRoutes.Contacts
|
||||
if (!onConversationsList) {
|
||||
backStack.navigateTopLevel(destination.route)
|
||||
} else {
|
||||
uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (currentKey != destination.route) {
|
||||
backStack.navigateTopLevel(destination.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
backStack.navigateTopLevel(destination.route)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MeshtasticNavigationBar(
|
||||
topLevelDestination: TopLevelDestination?,
|
||||
connectionState: ConnectionState,
|
||||
unreadMessageCount: Int,
|
||||
selectedDevice: String?,
|
||||
uiViewModel: UIViewModel,
|
||||
onNavigate: (TopLevelDestination) -> Unit,
|
||||
) {
|
||||
NavigationBar {
|
||||
TopLevelDestination.entries.forEach { destination ->
|
||||
NavigationBarItem(
|
||||
selected = destination == topLevelDestination,
|
||||
onClick = { onNavigate(destination) },
|
||||
icon = {
|
||||
NavigationIconContent(
|
||||
destination = destination,
|
||||
isSelected = destination == topLevelDestination,
|
||||
connectionState = connectionState,
|
||||
unreadMessageCount = unreadMessageCount,
|
||||
selectedDevice = selectedDevice,
|
||||
uiViewModel = uiViewModel,
|
||||
)
|
||||
},
|
||||
label = { Text(stringResource(destination.label)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MeshtasticNavigationRail(
|
||||
topLevelDestination: TopLevelDestination?,
|
||||
connectionState: ConnectionState,
|
||||
unreadMessageCount: Int,
|
||||
selectedDevice: String?,
|
||||
uiViewModel: UIViewModel,
|
||||
onNavigate: (TopLevelDestination) -> Unit,
|
||||
) {
|
||||
NavigationRail {
|
||||
TopLevelDestination.entries.forEach { destination ->
|
||||
NavigationRailItem(
|
||||
selected = destination == topLevelDestination,
|
||||
onClick = { onNavigate(destination) },
|
||||
icon = {
|
||||
NavigationIconContent(
|
||||
destination = destination,
|
||||
isSelected = destination == topLevelDestination,
|
||||
connectionState = connectionState,
|
||||
unreadMessageCount = unreadMessageCount,
|
||||
selectedDevice = selectedDevice,
|
||||
uiViewModel = uiViewModel,
|
||||
)
|
||||
},
|
||||
label = { Text(stringResource(destination.label)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun NavigationIconContent(
|
||||
destination: TopLevelDestination,
|
||||
isSelected: Boolean,
|
||||
connectionState: ConnectionState,
|
||||
unreadMessageCount: Int,
|
||||
selectedDevice: String?,
|
||||
uiViewModel: UIViewModel,
|
||||
) {
|
||||
val isConnectionsRoute = destination == TopLevelDestination.Connections
|
||||
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text(
|
||||
if (isConnectionsRoute) {
|
||||
when (connectionState) {
|
||||
ConnectionState.Connected -> stringResource(Res.string.connected)
|
||||
ConnectionState.Connecting -> stringResource(Res.string.connecting)
|
||||
ConnectionState.DeviceSleep -> stringResource(Res.string.device_sleeping)
|
||||
ConnectionState.Disconnected -> stringResource(Res.string.disconnected)
|
||||
}
|
||||
} else {
|
||||
stringResource(destination.label)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
if (isConnectionsRoute) {
|
||||
AnimatedConnectionsNavIcon(
|
||||
connectionState = connectionState,
|
||||
deviceType = DeviceType.fromAddress(selectedDevice ?: "NoDevice"),
|
||||
meshActivityFlow = uiViewModel.meshActivity,
|
||||
)
|
||||
} else {
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (destination == TopLevelDestination.Conversations) {
|
||||
var lastNonZeroCount by remember { mutableIntStateOf(unreadMessageCount) }
|
||||
if (unreadMessageCount > 0) {
|
||||
lastNonZeroCount = unreadMessageCount
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = unreadMessageCount > 0,
|
||||
enter = scaleIn() + fadeIn(),
|
||||
exit = scaleOut() + fadeOut(),
|
||||
) {
|
||||
Badge { Text(lastNonZeroCount.toString()) }
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Crossfade(isSelected, label = "BottomBarIcon") { isSelectedState ->
|
||||
Icon(
|
||||
imageVector = destination.icon,
|
||||
contentDescription = stringResource(destination.label),
|
||||
tint = if (isSelectedState) colorScheme.primary else LocalContentColor.current,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import org.meshtastic.core.ui.component.PlaceholderScreen
|
||||
|
||||
/**
|
||||
* Provides the platform-specific Map Main Screen. On Desktop or JVM targets where native maps aren't available yet, it
|
||||
* falls back to a [PlaceholderScreen].
|
||||
*/
|
||||
@Suppress("Wrapping")
|
||||
val LocalMapMainScreenProvider =
|
||||
compositionLocalOf<
|
||||
@Composable (onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) -> Unit,
|
||||
> {
|
||||
{ _, _, _ -> PlaceholderScreen("Map") }
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import org.meshtastic.core.ui.component.PlaceholderScreen
|
||||
|
||||
/**
|
||||
* Provides the platform-specific Map Screen for a Node (e.g. Google Maps or OSMDroid on Android). On Desktop or JVM
|
||||
* targets where native maps aren't available yet, it falls back to a [PlaceholderScreen].
|
||||
*/
|
||||
@Suppress("Wrapping")
|
||||
val LocalNodeMapScreenProvider =
|
||||
compositionLocalOf<@Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit> {
|
||||
{ destNum, _ -> PlaceholderScreen("Node Map ($destNum)") }
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright (c) 2026 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.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import org.meshtastic.core.ui.component.PlaceholderScreen
|
||||
|
||||
/**
|
||||
* Provides the platform-specific Traceroute Map Screen. On Desktop or JVM targets where native maps aren't available
|
||||
* yet, it falls back to a [PlaceholderScreen].
|
||||
*/
|
||||
@Suppress("Wrapping")
|
||||
val LocalTracerouteMapScreenProvider =
|
||||
compositionLocalOf<@Composable (destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) -> Unit> {
|
||||
{ _, _, _, _ -> PlaceholderScreen("Traceroute Map") }
|
||||
}
|
||||
|
|
@ -33,3 +33,15 @@ import org.jetbrains.compose.resources.StringResource
|
|||
|
||||
/** Returns a function to open the platform's browser with the given URL. */
|
||||
@Composable expect fun rememberOpenUrl(): (url: String) -> Unit
|
||||
|
||||
/** Returns a launcher function to prompt the user to save a file. The callback receives the saved file URI. */
|
||||
@Composable
|
||||
expect fun rememberSaveFileLauncher(
|
||||
onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
|
||||
): (defaultFilename: String, mimeType: String) -> Unit
|
||||
|
||||
/** Returns a launcher to request location permissions. */
|
||||
@Composable expect fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit
|
||||
|
||||
/** Returns a launcher to open the platform's location settings. */
|
||||
@Composable expect fun rememberOpenLocationSettings(): () -> Unit
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import org.jetbrains.compose.resources.StringResource
|
|||
import org.jetbrains.compose.resources.getString
|
||||
import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
|
||||
import org.meshtastic.core.database.entity.asDeviceVersion
|
||||
import org.meshtastic.core.model.MeshActivity
|
||||
import org.meshtastic.core.model.MyNodeInfo
|
||||
|
|
@ -44,6 +43,7 @@ import org.meshtastic.core.model.TracerouteMapAvailability
|
|||
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
|
||||
import org.meshtastic.core.model.service.TracerouteResponse
|
||||
import org.meshtastic.core.model.util.dispatchMeshtasticUri
|
||||
import org.meshtastic.core.repository.FirmwareReleaseRepository
|
||||
import org.meshtastic.core.repository.MeshLogRepository
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
|
|
@ -84,7 +84,7 @@ class UIViewModel(
|
|||
val snackbarManager: SnackbarManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _navigationDeepLink = MutableSharedFlow<MeshtasticUri>(replay = 1)
|
||||
private val _navigationDeepLink = MutableSharedFlow<List<androidx.navigation3.runtime.NavKey>>(replay = 1)
|
||||
val navigationDeepLink = _navigationDeepLink.asSharedFlow()
|
||||
|
||||
/**
|
||||
|
|
@ -100,8 +100,9 @@ class UIViewModel(
|
|||
val commonUri = org.meshtastic.core.common.util.CommonUri.parse(uri.uriString)
|
||||
|
||||
// Try navigation routing first
|
||||
if (org.meshtastic.core.navigation.DeepLinkRouter.route(commonUri) != null) {
|
||||
_navigationDeepLink.tryEmit(uri)
|
||||
val navKeys = org.meshtastic.core.navigation.DeepLinkRouter.route(commonUri)
|
||||
if (navKeys != null) {
|
||||
_navigationDeepLink.tryEmit(navKeys)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -127,6 +128,8 @@ class UIViewModel(
|
|||
/** Emits events for mesh network send/receive activity. */
|
||||
val meshActivity: Flow<MeshActivity> = radioInterfaceService.meshActivity
|
||||
|
||||
val currentDeviceAddressFlow: StateFlow<String?> = radioInterfaceService.currentDeviceAddressFlow
|
||||
|
||||
private val _scrollToTopEventFlow =
|
||||
MutableSharedFlow<ScrollToTopEvent>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
val scrollToTopEventFlow: Flow<ScrollToTopEvent> = _scrollToTopEventFlow.asSharedFlow()
|
||||
|
|
|
|||
|
|
@ -37,4 +37,13 @@ actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): A
|
|||
|
||||
@Composable actual fun rememberOpenUrl(): (url: String) -> Unit = { _ -> }
|
||||
|
||||
@Composable
|
||||
actual fun rememberSaveFileLauncher(
|
||||
onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
|
||||
): (defaultFilename: String, mimeType: String) -> Unit = { _, _ -> }
|
||||
|
||||
@Composable actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {}
|
||||
|
||||
@Composable actual fun rememberOpenLocationSettings(): () -> Unit = {}
|
||||
|
||||
@Composable actual fun SetScreenBrightness(brightness: Float) {}
|
||||
|
|
|
|||
|
|
@ -46,3 +46,20 @@ actual fun rememberOpenUrl(): (url: String) -> Unit = { url ->
|
|||
Logger.w(e) { "Failed to open URL: $url" }
|
||||
}
|
||||
}
|
||||
|
||||
/** JVM stub — Save file launcher is a no-op on desktop until implemented. */
|
||||
@Composable
|
||||
actual fun rememberSaveFileLauncher(
|
||||
onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
|
||||
): (defaultFilename: String, mimeType: String) -> Unit = { _, _ ->
|
||||
Logger.w { "File saving not implemented on Desktop" }
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {
|
||||
Logger.w { "Location permissions not implemented on Desktop" }
|
||||
onDenied()
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberOpenLocationSettings(): () -> Unit = { Logger.w { "Location settings not implemented on Desktop" } }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue