mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-16 12:59:00 -04:00
feat(node): consolidate node chip and menu (#1941)
This commit is contained in:
@@ -142,7 +142,6 @@ class MainActivity : AppCompatActivity(), Logging {
|
||||
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme()
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
|
||||
AppTheme(
|
||||
dynamicColor = dynamic,
|
||||
darkTheme = dark,
|
||||
@@ -447,14 +446,6 @@ class MainActivity : AppCompatActivity(), Logging {
|
||||
}
|
||||
}
|
||||
|
||||
model.tracerouteResponse.observe(this) { response ->
|
||||
model.showAlert(
|
||||
title = getString(R.string.traceroute),
|
||||
message = response ?: return@observe,
|
||||
)
|
||||
model.clearTracerouteResponse()
|
||||
}
|
||||
|
||||
try {
|
||||
bindMeshService()
|
||||
} catch (ex: BindFailedException) {
|
||||
|
||||
@@ -60,6 +60,7 @@ import com.geeksville.mesh.repository.location.LocationRepository
|
||||
import com.geeksville.mesh.repository.radio.RadioInterfaceService
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.geeksville.mesh.service.ServiceAction
|
||||
import com.geeksville.mesh.ui.components.NodeMenuAction
|
||||
import com.geeksville.mesh.ui.map.MAP_STYLE_ID
|
||||
import com.geeksville.mesh.util.getShortDate
|
||||
import com.geeksville.mesh.util.positionToMeter
|
||||
@@ -74,6 +75,7 @@ import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -544,6 +546,7 @@ class UIViewModel @Inject constructor(
|
||||
// Connection state to our radio device
|
||||
val connectionState get() = radioConfigRepository.connectionState
|
||||
fun isConnected() = connectionState.value != MeshService.ConnectionState.DISCONNECTED
|
||||
val isConnected = radioConfigRepository.connectionState.map { it == MeshService.ConnectionState.CONNECTED }
|
||||
|
||||
private val _requestChannelSet = MutableStateFlow<AppOnlyProtos.ChannelSet?>(null)
|
||||
val requestChannelSet: StateFlow<AppOnlyProtos.ChannelSet?> get() = _requestChannelSet
|
||||
@@ -590,6 +593,20 @@ class UIViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun handleNodeMenuAction(
|
||||
action: NodeMenuAction,
|
||||
) {
|
||||
when (action) {
|
||||
is NodeMenuAction.Remove -> removeNode(action.node.num)
|
||||
is NodeMenuAction.Ignore -> ignoreNode(action.node)
|
||||
is NodeMenuAction.Favorite -> favoriteNode(action.node)
|
||||
is NodeMenuAction.RequestUserInfo -> requestUserInfo(action.node.num)
|
||||
is NodeMenuAction.RequestPosition -> requestPosition(action.node.num)
|
||||
is NodeMenuAction.TraceRoute -> requestTraceroute(action.node.num)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// managed mode disables all access to configuration
|
||||
val isManaged: Boolean get() = config.device.isManaged || config.security.isManaged
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
package com.geeksville.mesh.navigation
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -223,6 +225,7 @@ fun NavDestination.showLongNameTitle(): Boolean {
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun NavGraph(
|
||||
@@ -230,90 +233,96 @@ fun NavGraph(
|
||||
uIViewModel: UIViewModel = hiltViewModel(),
|
||||
navController: NavHostController = rememberNavController(),
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = if (uIViewModel.bondedAddress.isNullOrBlank()) {
|
||||
Route.Settings
|
||||
} else {
|
||||
Route.Contacts
|
||||
},
|
||||
modifier = modifier,
|
||||
) {
|
||||
composable<Route.Contacts> {
|
||||
ContactsScreen(
|
||||
uIViewModel,
|
||||
onNavigate = { navController.navigate(Route.Messages(it)) }
|
||||
)
|
||||
}
|
||||
composable<Route.Nodes> {
|
||||
NodeScreen(
|
||||
model = uIViewModel,
|
||||
navigateToMessages = { navController.navigate(Route.Messages(it)) },
|
||||
navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) },
|
||||
)
|
||||
}
|
||||
composable<Route.Map> {
|
||||
MapView(uIViewModel)
|
||||
}
|
||||
composable<Route.Channels> {
|
||||
ChannelScreen(uIViewModel)
|
||||
}
|
||||
composable<Route.Settings>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink {
|
||||
uriPattern = "$DEEP_LINK_BASE_URI/settings"
|
||||
action = "android.intent.action.VIEW"
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
SettingsScreen(
|
||||
uIViewModel,
|
||||
) {
|
||||
navController.navigate(Route.RadioConfig()) {
|
||||
popUpTo(Route.Settings) {
|
||||
inclusive = false
|
||||
SharedTransitionLayout {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = if (uIViewModel.bondedAddress.isNullOrBlank()) {
|
||||
Route.Settings
|
||||
} else {
|
||||
Route.Contacts
|
||||
},
|
||||
modifier = modifier,
|
||||
) {
|
||||
composable<Route.Contacts> {
|
||||
ContactsScreen(
|
||||
uIViewModel,
|
||||
onNavigate = { navController.navigate(Route.Messages(it)) }
|
||||
)
|
||||
}
|
||||
composable<Route.Nodes> {
|
||||
NodeScreen(
|
||||
model = uIViewModel,
|
||||
navigateToMessages = { navController.navigate(Route.Messages(it)) },
|
||||
navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) },
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
this@composable,
|
||||
)
|
||||
}
|
||||
composable<Route.Map> {
|
||||
MapView(uIViewModel)
|
||||
}
|
||||
composable<Route.Channels> {
|
||||
ChannelScreen(uIViewModel)
|
||||
}
|
||||
composable<Route.Settings>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink {
|
||||
uriPattern = "$DEEP_LINK_BASE_URI/settings"
|
||||
action = "android.intent.action.VIEW"
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
SettingsScreen(
|
||||
uIViewModel,
|
||||
) {
|
||||
navController.navigate(Route.RadioConfig()) {
|
||||
popUpTo(Route.Settings) {
|
||||
inclusive = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
composable<Route.DebugPanel> {
|
||||
DebugScreen()
|
||||
}
|
||||
composable<Route.Messages>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink {
|
||||
uriPattern = "$DEEP_LINK_BASE_URI/messages/{contactKey}?message={message}"
|
||||
action = "android.intent.action.VIEW"
|
||||
},
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Route.Messages>()
|
||||
MessageScreen(
|
||||
contactKey = args.contactKey,
|
||||
message = args.message,
|
||||
viewModel = uIViewModel,
|
||||
navigateToMessages = { navController.navigate(Route.Messages(it)) },
|
||||
navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) },
|
||||
onNavigateBack = navController::navigateUp
|
||||
)
|
||||
}
|
||||
composable<Route.QuickChat> {
|
||||
QuickChatScreen()
|
||||
}
|
||||
nodeDetailGraph(navController, uIViewModel)
|
||||
radioConfigGraph(navController, uIViewModel)
|
||||
composable<Route.Share>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink {
|
||||
uriPattern = "$DEEP_LINK_BASE_URI/share?message={message}"
|
||||
action = "android.intent.action.VIEW"
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val message = backStackEntry.toRoute<Route.Share>().message
|
||||
ShareScreen(uIViewModel) {
|
||||
navController.navigate(Route.Messages(it, message)) {
|
||||
popUpTo<Route.Share> { inclusive = true }
|
||||
composable<Route.DebugPanel> {
|
||||
DebugScreen()
|
||||
}
|
||||
composable<Route.Messages>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink {
|
||||
uriPattern = "$DEEP_LINK_BASE_URI/messages/{contactKey}?message={message}"
|
||||
action = "android.intent.action.VIEW"
|
||||
},
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val args = backStackEntry.toRoute<Route.Messages>()
|
||||
MessageScreen(
|
||||
contactKey = args.contactKey,
|
||||
message = args.message,
|
||||
viewModel = uIViewModel,
|
||||
navigateToMessages = { navController.navigate(Route.Messages(it)) },
|
||||
navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) },
|
||||
onNavigateBack = navController::navigateUp,
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedContentScope = this@composable,
|
||||
)
|
||||
}
|
||||
composable<Route.QuickChat> {
|
||||
QuickChatScreen()
|
||||
}
|
||||
nodeDetailGraph(navController, uIViewModel, sharedTransitionScope = this@SharedTransitionLayout)
|
||||
radioConfigGraph(navController, uIViewModel)
|
||||
composable<Route.Share>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink {
|
||||
uriPattern = "$DEEP_LINK_BASE_URI/share?message={message}"
|
||||
action = "android.intent.action.VIEW"
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val message = backStackEntry.toRoute<Route.Share>().message
|
||||
ShareScreen(uIViewModel) {
|
||||
navController.navigate(Route.Messages(it, message)) {
|
||||
popUpTo<Route.Share> { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
package com.geeksville.mesh.navigation
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CellTower
|
||||
import androidx.compose.material.icons.filled.LightMode
|
||||
@@ -43,9 +45,11 @@ import com.geeksville.mesh.ui.components.PowerMetricsScreen
|
||||
import com.geeksville.mesh.ui.components.SignalMetricsScreen
|
||||
import com.geeksville.mesh.ui.components.TracerouteLogScreen
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
fun NavGraphBuilder.nodeDetailGraph(
|
||||
navController: NavHostController,
|
||||
uiViewModel: UIViewModel
|
||||
uiViewModel: UIViewModel,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
) {
|
||||
navigation<Graph.NodeDetailGraph>(
|
||||
startDestination = Route.NodeDetail(),
|
||||
@@ -54,7 +58,12 @@ fun NavGraphBuilder.nodeDetailGraph(
|
||||
val parentEntry = remember(backStackEntry) {
|
||||
navController.getBackStackEntry<Graph.NodeDetailGraph>()
|
||||
}
|
||||
NodeDetailScreen(uiViewModel = uiViewModel, viewModel = hiltViewModel(parentEntry)) {
|
||||
NodeDetailScreen(
|
||||
uiViewModel = uiViewModel,
|
||||
viewModel = hiltViewModel(parentEntry),
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = this@composable,
|
||||
) {
|
||||
navController.navigate(it) {
|
||||
popUpTo(Route.NodeDetail()) {
|
||||
inclusive = false
|
||||
@@ -71,7 +80,12 @@ fun NavGraphBuilder.nodeDetailGraph(
|
||||
NodeDetailRoute.DEVICE -> DeviceMetricsScreen(hiltViewModel(parentEntry))
|
||||
NodeDetailRoute.NODE_MAP -> NodeMapScreen(hiltViewModel(parentEntry))
|
||||
NodeDetailRoute.POSITION_LOG -> PositionLogScreen(hiltViewModel(parentEntry))
|
||||
NodeDetailRoute.ENVIRONMENT -> EnvironmentMetricsScreen(hiltViewModel(parentEntry))
|
||||
NodeDetailRoute.ENVIRONMENT -> EnvironmentMetricsScreen(
|
||||
hiltViewModel(
|
||||
parentEntry
|
||||
)
|
||||
)
|
||||
|
||||
NodeDetailRoute.SIGNAL -> SignalMetricsScreen(hiltViewModel(parentEntry))
|
||||
NodeDetailRoute.TRACEROUTE -> TracerouteLogScreen(hiltViewModel(parentEntry))
|
||||
NodeDetailRoute.POWER -> PowerMetricsScreen(hiltViewModel(parentEntry))
|
||||
|
||||
@@ -50,6 +50,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -133,6 +134,17 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val traceRouteResponse by viewModel.tracerouteResponse.observeAsState()
|
||||
traceRouteResponse?.let { response ->
|
||||
SimpleAlertDialog(
|
||||
title = R.string.traceroute,
|
||||
text = {
|
||||
Text(text = response)
|
||||
},
|
||||
onDismiss = { viewModel.clearTracerouteResponse() }
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
|
||||
114
app/src/main/java/com/geeksville/mesh/ui/NodeChip.kt
Normal file
114
app/src/main/java/com/geeksville/mesh/ui/NodeChip.kt
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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 com.geeksville.mesh.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.ElevatedAssistChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.model.Node
|
||||
import com.geeksville.mesh.ui.components.NodeMenu
|
||||
import com.geeksville.mesh.ui.components.NodeMenuAction
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun NodeChip(
|
||||
modifier: Modifier = Modifier,
|
||||
node: Node,
|
||||
isThisNode: Boolean,
|
||||
isConnected: Boolean,
|
||||
onAction: (NodeMenuAction) -> Unit,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedContentScope: AnimatedContentScope,
|
||||
) {
|
||||
val isIgnored = node.isIgnored
|
||||
val (textColor, nodeColor) = node.colors
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
val inputChipInteractionSource = remember { MutableInteractionSource() }
|
||||
Box {
|
||||
with(sharedTransitionScope) {
|
||||
ElevatedAssistChip(
|
||||
modifier = modifier
|
||||
.width(IntrinsicSize.Min)
|
||||
.defaultMinSize(minHeight = 32.dp, minWidth = 72.dp)
|
||||
.sharedElement(
|
||||
rememberSharedContentState("node_chip_${node.num}"),
|
||||
animatedContentScope
|
||||
),
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = Color(nodeColor),
|
||||
labelColor = Color(textColor),
|
||||
),
|
||||
label = {
|
||||
Text(
|
||||
modifier = Modifier.Companion.fillMaxWidth().sharedElement(
|
||||
rememberSharedContentState("node_shortname_${node.num}"),
|
||||
animatedContentScope
|
||||
),
|
||||
text = node.user.shortName.ifEmpty { "???" },
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
textDecoration = TextDecoration.Companion.LineThrough.takeIf { isIgnored },
|
||||
textAlign = TextAlign.Companion.Center,
|
||||
)
|
||||
},
|
||||
onClick = {},
|
||||
interactionSource = inputChipInteractionSource,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.Companion
|
||||
.matchParentSize()
|
||||
.combinedClickable(
|
||||
onClick = { onAction(NodeMenuAction.MoreDetails(node)) },
|
||||
onLongClick = { menuExpanded = true },
|
||||
interactionSource = inputChipInteractionSource,
|
||||
indication = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
NodeMenu(
|
||||
expanded = menuExpanded,
|
||||
node = node,
|
||||
showFullMenu = !isThisNode && isConnected,
|
||||
onDismissMenuRequest = { menuExpanded = false },
|
||||
onAction = {
|
||||
menuExpanded = false
|
||||
onAction(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -15,9 +15,15 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalSharedTransitionApi::class)
|
||||
|
||||
package com.geeksville.mesh.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -32,13 +38,17 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.automirrored.outlined.VolumeMute
|
||||
import androidx.compose.material.icons.filled.Air
|
||||
import androidx.compose.material.icons.filled.BlurOn
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.ChargingStation
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Height
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.KeyOff
|
||||
@@ -56,16 +66,23 @@ import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.SignalCellularAlt
|
||||
import androidx.compose.material.icons.filled.Speed
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material.icons.filled.Thermostat
|
||||
import androidx.compose.material.icons.filled.WaterDrop
|
||||
import androidx.compose.material.icons.filled.Work
|
||||
import androidx.compose.material.icons.outlined.Navigation
|
||||
import androidx.compose.material.icons.outlined.NoCell
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material.icons.twotone.Person
|
||||
import androidx.compose.material.icons.twotone.Verified
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -83,6 +100,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -104,10 +122,12 @@ import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.model.isUnmessageableRole
|
||||
import com.geeksville.mesh.navigation.Route
|
||||
import com.geeksville.mesh.service.ServiceAction
|
||||
import com.geeksville.mesh.ui.components.NodeActionDialogs
|
||||
import com.geeksville.mesh.ui.components.NodeMenuAction
|
||||
import com.geeksville.mesh.ui.components.PreferenceCategory
|
||||
import com.geeksville.mesh.ui.components.SharedTransitionPreview
|
||||
import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider
|
||||
import com.geeksville.mesh.ui.radioconfig.NavCard
|
||||
import com.geeksville.mesh.ui.theme.AppTheme
|
||||
import com.geeksville.mesh.util.UnitConversions.calculateDewPoint
|
||||
import com.geeksville.mesh.util.UnitConversions.toTempString
|
||||
import com.geeksville.mesh.util.formatAgo
|
||||
@@ -129,11 +149,14 @@ private enum class LogsType(
|
||||
TRACEROUTE(R.string.traceroute_log, Icons.Default.Route, Route.TracerouteLog)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun NodeDetailScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MetricsViewModel = hiltViewModel(),
|
||||
uiViewModel: UIViewModel = hiltViewModel(),
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedContentScope: AnimatedContentScope,
|
||||
onNavigate: (Route) -> Unit = {},
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
@@ -168,10 +191,17 @@ fun NodeDetailScreen(
|
||||
when (action) {
|
||||
is Route -> onNavigate(action)
|
||||
is ServiceAction -> viewModel.onServiceAction(action)
|
||||
is NodeMenuAction -> {
|
||||
uiViewModel.handleNodeMenuAction(action)
|
||||
}
|
||||
|
||||
else -> debug("Unhandled action: $action")
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
metricsAvailability = availabilities,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope,
|
||||
onShared = {
|
||||
share = true
|
||||
}
|
||||
@@ -194,6 +224,8 @@ private fun NodeDetailList(
|
||||
metricsState: MetricsState,
|
||||
onAction: (Any) -> Unit = {},
|
||||
metricsAvailability: BooleanArray,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedContentScope: AnimatedContentScope,
|
||||
onShared: () -> Unit = {}
|
||||
) {
|
||||
LazyColumn(
|
||||
@@ -203,13 +235,13 @@ private fun NodeDetailList(
|
||||
if (metricsState.deviceHardware != null) {
|
||||
item {
|
||||
PreferenceCategory(stringResource(R.string.device)) {
|
||||
DeviceDetailsContent(metricsState)
|
||||
DeviceDetailsContent(metricsState, sharedTransitionScope, animatedContentScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
PreferenceCategory(stringResource(R.string.details)) {
|
||||
NodeDetailsContent(node)
|
||||
NodeDetailsContent(node, sharedTransitionScope, animatedContentScope)
|
||||
}
|
||||
}
|
||||
node.metadata?.firmwareVersion?.let { firmwareVersion ->
|
||||
@@ -296,13 +328,14 @@ private fun NodeDetailList(
|
||||
|
||||
@Composable
|
||||
private fun NodeDetailRow(
|
||||
modifier: Modifier = Modifier,
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
value: String,
|
||||
iconTint: Color = MaterialTheme.colorScheme.onSurface
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -320,15 +353,31 @@ private fun NodeDetailRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun DeviceActions(
|
||||
isLocal: Boolean = false,
|
||||
node: Node,
|
||||
onShared: () -> Unit,
|
||||
onAction: (ServiceAction) -> Unit,
|
||||
onAction: (Any) -> Unit,
|
||||
) {
|
||||
var displayFavoriteDialog by remember { mutableStateOf(false) }
|
||||
var displayIgnoreDialog by remember { mutableStateOf(false) }
|
||||
var displayRemoveDialog by remember { mutableStateOf(false) }
|
||||
NodeActionDialogs(
|
||||
node = node,
|
||||
displayFavoriteDialog = displayFavoriteDialog,
|
||||
displayIgnoreDialog = displayIgnoreDialog,
|
||||
displayRemoveDialog = displayRemoveDialog,
|
||||
onDismissMenuRequest = {
|
||||
displayFavoriteDialog = false
|
||||
displayIgnoreDialog = false
|
||||
displayRemoveDialog = false
|
||||
},
|
||||
onAction = onAction,
|
||||
)
|
||||
PreferenceCategory(text = stringResource(R.string.actions))
|
||||
NavCard(
|
||||
NodeActionButton(
|
||||
title = stringResource(id = R.string.share_contact),
|
||||
icon = Icons.Default.Share,
|
||||
enabled = true,
|
||||
@@ -336,35 +385,94 @@ private fun DeviceActions(
|
||||
)
|
||||
|
||||
if (!isLocal) {
|
||||
NavCard(
|
||||
NodeActionButton(
|
||||
title = stringResource(id = R.string.request_metadata),
|
||||
icon = Icons.Default.Memory,
|
||||
enabled = true,
|
||||
onClick = { onAction(ServiceAction.GetDeviceMetadata(node.num)) }
|
||||
)
|
||||
NodeActionButton(
|
||||
title = stringResource(id = R.string.exchange_position),
|
||||
icon = Icons.Default.LocationOn,
|
||||
enabled = true,
|
||||
onClick = { onAction(NodeMenuAction.RequestPosition(node)) }
|
||||
)
|
||||
NodeActionButton(
|
||||
title = stringResource(id = R.string.exchange_userinfo),
|
||||
icon = Icons.Default.Person,
|
||||
enabled = true,
|
||||
onClick = { onAction(NodeMenuAction.RequestUserInfo(node)) }
|
||||
)
|
||||
NodeActionButton(
|
||||
title = stringResource(id = R.string.traceroute),
|
||||
icon = Icons.Default.Route,
|
||||
enabled = true,
|
||||
onClick = { onAction(NodeMenuAction.TraceRoute(node)) }
|
||||
)
|
||||
NodeActionSwitch(
|
||||
title = stringResource(R.string.favorite),
|
||||
icon = if (node.isFavorite) {
|
||||
Icons.Default.Star
|
||||
} else {
|
||||
Icons.Default.StarBorder
|
||||
},
|
||||
iconTint = if (node.isFavorite) {
|
||||
Color.Yellow
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
},
|
||||
enabled = true,
|
||||
checked = node.isFavorite,
|
||||
onClick = { displayFavoriteDialog = true }
|
||||
)
|
||||
NodeActionSwitch(
|
||||
title = stringResource(R.string.ignore),
|
||||
icon = if (node.isIgnored) {
|
||||
Icons.AutoMirrored.Outlined.VolumeMute
|
||||
} else {
|
||||
Icons.AutoMirrored.Default.VolumeUp
|
||||
},
|
||||
enabled = true,
|
||||
checked = node.isIgnored,
|
||||
onClick = { displayIgnoreDialog = true }
|
||||
)
|
||||
NodeActionButton(
|
||||
title = stringResource(id = R.string.remove),
|
||||
icon = Icons.Default.Delete,
|
||||
enabled = true,
|
||||
onClick = { displayRemoveDialog = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceDetailsContent(
|
||||
state: MetricsState,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedContentScope: AnimatedContentScope,
|
||||
) {
|
||||
val node = state.node ?: return
|
||||
val deviceHardware = state.deviceHardware ?: return
|
||||
val hwModelName = deviceHardware.displayName
|
||||
val isSupported = deviceHardware.activelySupported
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.padding(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
color = Color(node.colors.second).copy(alpha = .5f),
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize())
|
||||
with(sharedTransitionScope) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.padding(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
color = Color(node.colors.second).copy(alpha = .5f),
|
||||
shape = CircleShape
|
||||
)
|
||||
.sharedElement(
|
||||
rememberSharedContentState("node_chip_${node.num}"),
|
||||
animatedContentScope
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.hardware),
|
||||
@@ -384,7 +492,9 @@ fun DeviceHardwareImage(
|
||||
deviceHardware: DeviceHardware,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hwImg = deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0) ?: "unknown.svg"
|
||||
val hwImg =
|
||||
deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0)
|
||||
?: "unknown.svg"
|
||||
val imageUrl = "https://flasher.meshtastic.org/img/devices/$hwImg"
|
||||
val listener = object : ImageRequest.Listener {
|
||||
override fun onStart(request: ImageRequest) {
|
||||
@@ -421,6 +531,8 @@ fun DeviceHardwareImage(
|
||||
@Composable
|
||||
private fun NodeDetailsContent(
|
||||
node: Node,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedContentScope: AnimatedContentScope,
|
||||
) {
|
||||
if (node.mismatchKey) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -444,6 +556,22 @@ private fun NodeDetailsContent(
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.long_name),
|
||||
icon = Icons.TwoTone.Person,
|
||||
value = node.user.longName.ifEmpty { "???" }
|
||||
)
|
||||
with(sharedTransitionScope) {
|
||||
NodeDetailRow(
|
||||
modifier = Modifier.sharedElement(
|
||||
rememberSharedContentState("node_shortname_${node.num}"),
|
||||
animatedContentScope
|
||||
),
|
||||
label = stringResource(R.string.short_name),
|
||||
icon = Icons.Outlined.Person,
|
||||
value = node.user.shortName.ifEmpty { "???" }
|
||||
)
|
||||
}
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.node_number),
|
||||
icon = Icons.Default.Numbers,
|
||||
@@ -695,17 +823,109 @@ private fun PowerMetrics(node: Node) = with(node.powerMetrics) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NodeActionButton(
|
||||
title: String,
|
||||
enabled: Boolean,
|
||||
icon: ImageVector? = null,
|
||||
iconTint: Color? = null,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.height(48.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = iconTint ?: LocalContentColor.current,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NodeActionSwitch(
|
||||
title: String,
|
||||
enabled: Boolean,
|
||||
checked: Boolean,
|
||||
icon: ImageVector? = null,
|
||||
iconTint: Color? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.height(48.dp)
|
||||
.toggleable(
|
||||
value = checked,
|
||||
enabled = enabled,
|
||||
role = Role.Switch,
|
||||
onValueChange = { onClick() }
|
||||
),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp, horizontal = 16.dp)
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = iconTint ?: LocalContentColor.current,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun NodeDetailsPreview(
|
||||
@PreviewParameter(NodePreviewParameterProvider::class)
|
||||
node: Node
|
||||
) {
|
||||
AppTheme {
|
||||
SharedTransitionPreview { sharedTransitionScope, animatedContentScope ->
|
||||
NodeDetailList(
|
||||
node = node,
|
||||
metricsState = MetricsState.Empty,
|
||||
metricsAvailability = BooleanArray(LogsType.entries.size) { false }
|
||||
metricsAvailability = BooleanArray(LogsType.entries.size) { false },
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,11 @@
|
||||
package com.geeksville.mesh.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
@@ -32,21 +31,16 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -62,16 +56,16 @@ import com.geeksville.mesh.model.DeviceVersion
|
||||
import com.geeksville.mesh.model.Node
|
||||
import com.geeksville.mesh.model.isUnmessageableRole
|
||||
import com.geeksville.mesh.ui.components.NodeKeyStatusIcon
|
||||
import com.geeksville.mesh.ui.components.NodeMenu
|
||||
import com.geeksville.mesh.ui.components.NodeMenuAction
|
||||
import com.geeksville.mesh.ui.components.NodeStatusIcons
|
||||
import com.geeksville.mesh.ui.components.SharedTransitionPreview
|
||||
import com.geeksville.mesh.ui.components.SignalInfo
|
||||
import com.geeksville.mesh.ui.compose.ElevationInfo
|
||||
import com.geeksville.mesh.ui.compose.SatelliteCountInfo
|
||||
import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider
|
||||
import com.geeksville.mesh.ui.theme.AppTheme
|
||||
import com.geeksville.mesh.util.toDistanceString
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun NodeItem(
|
||||
@@ -85,17 +79,17 @@ fun NodeItem(
|
||||
expanded: Boolean = false,
|
||||
currentTimeMillis: Long,
|
||||
isConnected: Boolean = false,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedContentScope: AnimatedContentScope,
|
||||
) {
|
||||
val isFavorite = remember(thatNode) { thatNode.isFavorite }
|
||||
val isIgnored = thatNode.isIgnored
|
||||
val longName = thatNode.user.longName.ifEmpty { stringResource(id = R.string.unknown_username) }
|
||||
|
||||
val isThisNode = remember(thatNode) { thisNode?.num == thatNode.num }
|
||||
val system = remember(distanceUnits) { DisplayConfig.DisplayUnits.forNumber(distanceUnits) }
|
||||
val distance = remember(thisNode, thatNode) {
|
||||
thisNode?.distance(thatNode)?.takeIf { it > 0 }?.toDistanceString(system)
|
||||
}
|
||||
val (textColor, nodeColor) = thatNode.colors
|
||||
|
||||
val hwInfoString = when (val hwModel = thatNode.user.hwModel) {
|
||||
MeshProtos.HardwareModel.UNSET -> MeshProtos.HardwareModel.UNSET.name
|
||||
@@ -122,7 +116,6 @@ fun NodeItem(
|
||||
thatNode.user.role?.isUnmessageableRole() == true
|
||||
}
|
||||
}
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
@@ -141,46 +134,15 @@ fun NodeItem(
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val inputChipInteractionSource = remember { MutableInteractionSource() }
|
||||
Box {
|
||||
AssistChip(
|
||||
modifier = Modifier
|
||||
.width(IntrinsicSize.Min)
|
||||
.defaultMinSize(minHeight = 32.dp, minWidth = 72.dp),
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = Color(nodeColor),
|
||||
labelColor = Color(textColor),
|
||||
),
|
||||
label = {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = thatNode.user.shortName.ifEmpty { "???" },
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
},
|
||||
onClick = {},
|
||||
interactionSource = inputChipInteractionSource,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.combinedClickable(
|
||||
onClick = { onAction(NodeMenuAction.MoreDetails(thatNode)) },
|
||||
onLongClick = { menuExpanded = true },
|
||||
interactionSource = inputChipInteractionSource,
|
||||
indication = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
NodeMenu(
|
||||
NodeChip(
|
||||
node = thatNode,
|
||||
showFullMenu = !isThisNode && isConnected,
|
||||
isThisNode = isThisNode,
|
||||
isConnected = isConnected,
|
||||
onAction = onAction,
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false },
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope,
|
||||
)
|
||||
|
||||
NodeKeyStatusIcon(
|
||||
hasPKC = thatNode.hasPKC,
|
||||
mismatchKey = thatNode.mismatchKey,
|
||||
@@ -309,10 +271,11 @@ fun NodeItem(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
@Preview(showBackground = false)
|
||||
fun NodeInfoSimplePreview() {
|
||||
AppTheme {
|
||||
SharedTransitionPreview { sharedTransitionScope, animatedContentScope ->
|
||||
val thisNode = NodePreviewParameterProvider().values.first()
|
||||
val thatNode = NodePreviewParameterProvider().values.last()
|
||||
NodeItem(
|
||||
@@ -321,11 +284,14 @@ fun NodeInfoSimplePreview() {
|
||||
1,
|
||||
0,
|
||||
true,
|
||||
currentTimeMillis = System.currentTimeMillis()
|
||||
currentTimeMillis = System.currentTimeMillis(),
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
@Preview(
|
||||
showBackground = true,
|
||||
@@ -335,7 +301,7 @@ fun NodeInfoPreview(
|
||||
@PreviewParameter(NodePreviewParameterProvider::class)
|
||||
thatNode: Node
|
||||
) {
|
||||
AppTheme {
|
||||
SharedTransitionPreview { sharedTransitionScope, animatedContentScope ->
|
||||
val thisNode = NodePreviewParameterProvider().values.first()
|
||||
Column {
|
||||
Text(
|
||||
@@ -349,7 +315,9 @@ fun NodeInfoPreview(
|
||||
distanceUnits = 1,
|
||||
tempInFahrenheit = true,
|
||||
expanded = false,
|
||||
currentTimeMillis = System.currentTimeMillis()
|
||||
currentTimeMillis = System.currentTimeMillis(),
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope,
|
||||
)
|
||||
Text(
|
||||
text = "Details Shown",
|
||||
@@ -362,7 +330,9 @@ fun NodeInfoPreview(
|
||||
distanceUnits = 1,
|
||||
tempInFahrenheit = true,
|
||||
expanded = true,
|
||||
currentTimeMillis = System.currentTimeMillis()
|
||||
currentTimeMillis = System.currentTimeMillis(),
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
|
||||
package com.geeksville.mesh.ui
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
@@ -49,13 +51,15 @@ import com.geeksville.mesh.ui.components.NodeFilterTextField
|
||||
import com.geeksville.mesh.ui.components.NodeMenuAction
|
||||
import com.geeksville.mesh.ui.components.rememberTimeTickWithLifecycle
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun NodeScreen(
|
||||
model: UIViewModel = hiltViewModel(),
|
||||
navigateToMessages: (String) -> Unit,
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedContentScope: AnimatedContentScope,
|
||||
) {
|
||||
val state by model.nodesUiState.collectAsStateWithLifecycle()
|
||||
|
||||
@@ -135,6 +139,8 @@ fun NodeScreen(
|
||||
expanded = state.showDetails,
|
||||
currentTimeMillis = currentTimeMillis,
|
||||
isConnected = connectionState.isConnected(),
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -144,8 +150,7 @@ fun NodeScreen(
|
||||
|
||||
AnimatedVisibility(
|
||||
modifier = Modifier.align(androidx.compose.ui.Alignment.BottomEnd),
|
||||
visible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
|
||||
!listState.isScrollInProgress &&
|
||||
visible = !listState.isScrollInProgress &&
|
||||
connectionState.isConnected() &&
|
||||
shareCapable
|
||||
) {
|
||||
|
||||
@@ -45,10 +45,10 @@ import com.geeksville.mesh.model.isUnmessageableRole
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun NodeMenu(
|
||||
expanded: Boolean,
|
||||
node: Node,
|
||||
showFullMenu: Boolean = false,
|
||||
onDismissRequest: () -> Unit,
|
||||
expanded: Boolean = false,
|
||||
onDismissMenuRequest: () -> Unit,
|
||||
onAction: (NodeMenuAction) -> Unit,
|
||||
) {
|
||||
val isUnmessageable = if (node.user.hasIsUnmessagable()) {
|
||||
@@ -57,94 +57,69 @@ fun NodeMenu(
|
||||
// for older firmwares
|
||||
node.user.role?.isUnmessageableRole() == true
|
||||
}
|
||||
|
||||
var displayFavoriteDialog by remember { mutableStateOf(false) }
|
||||
var displayIgnoreDialog by remember { mutableStateOf(false) }
|
||||
var displayRemoveDialog by remember { mutableStateOf(false) }
|
||||
if (displayFavoriteDialog) {
|
||||
SimpleAlertDialog(
|
||||
title = R.string.favorite,
|
||||
text = stringResource(
|
||||
id = if (node.isFavorite) R.string.favorite_remove else R.string.favorite_add,
|
||||
node.user.longName
|
||||
),
|
||||
onConfirm = {
|
||||
displayFavoriteDialog = false
|
||||
onAction(NodeMenuAction.Favorite(node))
|
||||
},
|
||||
onDismiss = {
|
||||
displayFavoriteDialog = false
|
||||
}
|
||||
)
|
||||
val dialogDismissRequest = {
|
||||
displayFavoriteDialog = false
|
||||
displayIgnoreDialog = false
|
||||
displayRemoveDialog = false
|
||||
onDismissMenuRequest()
|
||||
}
|
||||
if (displayIgnoreDialog) {
|
||||
SimpleAlertDialog(
|
||||
title = R.string.ignore,
|
||||
text = stringResource(
|
||||
id = if (node.isIgnored) R.string.ignore_remove else R.string.ignore_add,
|
||||
node.user.longName
|
||||
),
|
||||
onConfirm = {
|
||||
displayIgnoreDialog = false
|
||||
onAction(NodeMenuAction.Ignore(node))
|
||||
},
|
||||
onDismiss = {
|
||||
displayIgnoreDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
if (displayRemoveDialog) {
|
||||
SimpleAlertDialog(
|
||||
title = R.string.remove,
|
||||
text = R.string.remove_node_text,
|
||||
onConfirm = {
|
||||
displayRemoveDialog = false
|
||||
onAction(NodeMenuAction.Remove(node))
|
||||
},
|
||||
onDismiss = {
|
||||
displayRemoveDialog = false
|
||||
}
|
||||
)
|
||||
val onMenuAction: (NodeMenuAction) -> Unit = {
|
||||
dialogDismissRequest()
|
||||
onDismissMenuRequest()
|
||||
onAction(it)
|
||||
}
|
||||
NodeActionDialogs(
|
||||
node = node,
|
||||
displayFavoriteDialog = displayFavoriteDialog,
|
||||
displayIgnoreDialog = displayIgnoreDialog,
|
||||
displayRemoveDialog = displayRemoveDialog,
|
||||
onDismissMenuRequest = dialogDismissRequest,
|
||||
onAction = onMenuAction
|
||||
)
|
||||
DropdownMenu(
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.background.copy(alpha = 1f)),
|
||||
expanded = expanded,
|
||||
onDismissRequest = onDismissRequest,
|
||||
onDismissRequest = onDismissMenuRequest,
|
||||
) {
|
||||
|
||||
if (showFullMenu) {
|
||||
if (!isUnmessageable) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onAction(NodeMenuAction.DirectMessage(node))
|
||||
dialogDismissRequest()
|
||||
onMenuAction(NodeMenuAction.DirectMessage(node))
|
||||
},
|
||||
text = { Text(stringResource(R.string.direct_message)) }
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onAction(NodeMenuAction.RequestUserInfo(node))
|
||||
dialogDismissRequest()
|
||||
onMenuAction(NodeMenuAction.RequestUserInfo(node))
|
||||
},
|
||||
text = { Text(stringResource(R.string.exchange_userinfo)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onAction(NodeMenuAction.RequestPosition(node))
|
||||
dialogDismissRequest()
|
||||
onMenuAction(NodeMenuAction.RequestPosition(node))
|
||||
},
|
||||
text = { Text(stringResource(R.string.exchange_position)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onAction(NodeMenuAction.TraceRoute(node))
|
||||
dialogDismissRequest()
|
||||
onMenuAction(NodeMenuAction.TraceRoute(node))
|
||||
},
|
||||
text = { Text(stringResource(R.string.traceroute)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
dialogDismissRequest()
|
||||
displayFavoriteDialog = true
|
||||
},
|
||||
enabled = !node.isIgnored,
|
||||
@@ -160,7 +135,7 @@ fun NodeMenu(
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
dialogDismissRequest()
|
||||
displayIgnoreDialog = true
|
||||
},
|
||||
text = {
|
||||
@@ -170,7 +145,7 @@ fun NodeMenu(
|
||||
Checkbox(
|
||||
checked = node.isIgnored,
|
||||
onCheckedChange = {
|
||||
onDismissRequest()
|
||||
dialogDismissRequest()
|
||||
displayIgnoreDialog = true
|
||||
},
|
||||
modifier = Modifier.size(24.dp),
|
||||
@@ -179,7 +154,7 @@ fun NodeMenu(
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
dialogDismissRequest()
|
||||
displayRemoveDialog = true
|
||||
},
|
||||
enabled = !node.isIgnored,
|
||||
@@ -189,22 +164,72 @@ fun NodeMenu(
|
||||
}
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onAction(NodeMenuAction.Share(node))
|
||||
dialogDismissRequest()
|
||||
onMenuAction(NodeMenuAction.Share(node))
|
||||
},
|
||||
text = { Text(stringResource(R.string.share_contact)) }
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onAction(NodeMenuAction.MoreDetails(node))
|
||||
dialogDismissRequest()
|
||||
onMenuAction(NodeMenuAction.MoreDetails(node))
|
||||
},
|
||||
text = { Text(stringResource(R.string.more_details)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NodeActionDialogs(
|
||||
node: Node,
|
||||
displayFavoriteDialog: Boolean,
|
||||
displayIgnoreDialog: Boolean,
|
||||
displayRemoveDialog: Boolean,
|
||||
onDismissMenuRequest: () -> Unit,
|
||||
onAction: (NodeMenuAction) -> Unit
|
||||
) {
|
||||
if (displayFavoriteDialog) {
|
||||
SimpleAlertDialog(
|
||||
title = R.string.favorite,
|
||||
text = stringResource(
|
||||
id = if (node.isFavorite) R.string.favorite_remove else R.string.favorite_add,
|
||||
node.user.longName
|
||||
),
|
||||
onConfirm = {
|
||||
onDismissMenuRequest()
|
||||
onAction(NodeMenuAction.Favorite(node))
|
||||
},
|
||||
onDismiss = onDismissMenuRequest
|
||||
)
|
||||
}
|
||||
if (displayIgnoreDialog) {
|
||||
SimpleAlertDialog(
|
||||
title = R.string.ignore,
|
||||
text = stringResource(
|
||||
id = if (node.isIgnored) R.string.ignore_remove else R.string.ignore_add,
|
||||
node.user.longName
|
||||
),
|
||||
onConfirm = {
|
||||
onDismissMenuRequest()
|
||||
onAction(NodeMenuAction.Ignore(node))
|
||||
},
|
||||
onDismiss = onDismissMenuRequest
|
||||
)
|
||||
}
|
||||
if (displayRemoveDialog) {
|
||||
SimpleAlertDialog(
|
||||
title = R.string.remove,
|
||||
text = R.string.remove_node_text,
|
||||
onConfirm = {
|
||||
onDismissMenuRequest()
|
||||
onAction(NodeMenuAction.Remove(node))
|
||||
},
|
||||
onDismiss = onDismissMenuRequest
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class NodeMenuAction {
|
||||
data class Remove(val node: Node) : NodeMenuAction()
|
||||
data class Ignore(val node: Node) : NodeMenuAction()
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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 com.geeksville.mesh.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.geeksville.mesh.ui.theme.AppTheme
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun SharedTransitionPreview(
|
||||
content: @Composable (SharedTransitionScope, AnimatedContentScope) -> Unit
|
||||
) {
|
||||
AppTheme {
|
||||
SharedTransitionLayout {
|
||||
val sharedTransitionScope: SharedTransitionScope = this
|
||||
AnimatedContent(
|
||||
targetState = true,
|
||||
label = "SharedTransitionPreview",
|
||||
) {
|
||||
if (it) {
|
||||
val animatedContentScope: AnimatedContentScope = this
|
||||
content(sharedTransitionScope, animatedContentScope)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
* 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 com.geeksville.mesh.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import com.geeksville.mesh.model.Node
|
||||
import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider
|
||||
import com.geeksville.mesh.ui.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
fun UserAvatar(
|
||||
node: Node,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = {}
|
||||
) {
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
val textStyle = MaterialTheme.typography.labelLarge.copy(
|
||||
fontWeight = FontWeight.Normal,
|
||||
)
|
||||
val textLayoutResult = remember {
|
||||
textMeasurer.measure(text = "MMMM", style = textStyle)
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.size(with(LocalDensity.current) { textLayoutResult.size.width.toDp() })
|
||||
.background(
|
||||
color = Color(node.colors.second),
|
||||
shape = CircleShape
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Text(
|
||||
text = node.user.shortName.ifEmpty { "?" },
|
||||
color = Color(node.colors.first),
|
||||
style = textStyle,
|
||||
textAlign = TextAlign.Center,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun AvatarPreview(
|
||||
@PreviewParameter(NodePreviewParameterProvider::class)
|
||||
node: Node
|
||||
) {
|
||||
AppTheme {
|
||||
UserAvatar(node)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@
|
||||
package com.geeksville.mesh.ui.message
|
||||
|
||||
import android.content.ClipData
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -86,6 +89,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
private const val MESSAGE_CHARACTER_LIMIT = 200
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
internal fun MessageScreen(
|
||||
@@ -94,7 +98,9 @@ internal fun MessageScreen(
|
||||
viewModel: UIViewModel = hiltViewModel(),
|
||||
navigateToMessages: (String) -> Unit,
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
onNavigateBack: () -> Unit
|
||||
onNavigateBack: () -> Unit,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedContentScope: AnimatedContentScope,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboard.current
|
||||
@@ -222,25 +228,21 @@ internal fun MessageScreen(
|
||||
viewModel = viewModel,
|
||||
contactKey = contactKey,
|
||||
onNodeMenuAction = { action ->
|
||||
when (action) {
|
||||
is NodeMenuAction.Remove -> viewModel.removeNode(action.node.num)
|
||||
is NodeMenuAction.Ignore -> viewModel.ignoreNode(action.node)
|
||||
is NodeMenuAction.Favorite -> viewModel.favoriteNode(action.node)
|
||||
is NodeMenuAction.DirectMessage -> {
|
||||
val hasPKC =
|
||||
viewModel.ourNodeInfo.value?.hasPKC == true && action.node.hasPKC
|
||||
val channel =
|
||||
if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else action.node.channel
|
||||
navigateToMessages("$channel${action.node.user.id}")
|
||||
when (action) {
|
||||
is NodeMenuAction.DirectMessage -> {
|
||||
val hasPKC =
|
||||
viewModel.ourNodeInfo.value?.hasPKC == true && action.node.hasPKC
|
||||
val channel =
|
||||
if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else action.node.channel
|
||||
navigateToMessages("$channel${action.node.user.id}")
|
||||
}
|
||||
is NodeMenuAction.MoreDetails -> navigateToNodeDetails(action.node.num)
|
||||
is NodeMenuAction.Share -> sharedContact = action.node
|
||||
else -> viewModel.handleNodeMenuAction(action)
|
||||
}
|
||||
|
||||
is NodeMenuAction.RequestUserInfo -> viewModel.requestUserInfo(action.node.num)
|
||||
is NodeMenuAction.RequestPosition -> viewModel.requestPosition(action.node.num)
|
||||
is NodeMenuAction.TraceRoute -> viewModel.requestTraceroute(action.node.num)
|
||||
is NodeMenuAction.MoreDetails -> navigateToNodeDetails(action.node.num)
|
||||
is NodeMenuAction.Share -> sharedContact = action.node
|
||||
}
|
||||
}
|
||||
},
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -393,7 +395,8 @@ private fun TextInput(
|
||||
message.value = it
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusEvent { isFocused = it.isFocused },
|
||||
enabled = enabled,
|
||||
placeholder = { Text(stringResource(id = R.string.send_text)) },
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
|
||||
package com.geeksville.mesh.ui.message.components
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -52,13 +55,14 @@ import com.geeksville.mesh.DataPacket
|
||||
import com.geeksville.mesh.MessageStatus
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.Node
|
||||
import com.geeksville.mesh.ui.NodeChip
|
||||
import com.geeksville.mesh.ui.components.AutoLinkText
|
||||
import com.geeksville.mesh.ui.components.UserAvatar
|
||||
import com.geeksville.mesh.ui.components.NodeMenuAction
|
||||
import com.geeksville.mesh.ui.components.SharedTransitionPreview
|
||||
import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider
|
||||
import com.geeksville.mesh.ui.theme.AppTheme
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
internal fun MessageItem(
|
||||
node: Node,
|
||||
@@ -69,9 +73,12 @@ internal fun MessageItem(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = {},
|
||||
onLongClick: () -> Unit = {},
|
||||
onChipClick: () -> Unit = {},
|
||||
onAction: (NodeMenuAction) -> Unit = {},
|
||||
onStatusClick: () -> Unit = {},
|
||||
onSendReaction: (String) -> Unit = {},
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedContentScope: AnimatedContentScope,
|
||||
isConnected: Boolean,
|
||||
) = Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
@@ -91,12 +98,16 @@ internal fun MessageItem(
|
||||
Modifier.padding(start = 8.dp, top = 8.dp, end = 0.dp, bottom = 6.dp)
|
||||
}
|
||||
if (!fromLocal) {
|
||||
UserAvatar(
|
||||
NodeChip(
|
||||
node = node,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, top = 8.dp)
|
||||
.align(Alignment.Top),
|
||||
) { onChipClick() }
|
||||
.padding(start = 8.dp, end = 4.dp),
|
||||
onAction = onAction,
|
||||
isConnected = isConnected,
|
||||
isThisNode = false,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope,
|
||||
)
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier
|
||||
@@ -166,16 +177,20 @@ internal fun MessageItem(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun MessageItemPreview() {
|
||||
AppTheme {
|
||||
SharedTransitionPreview { sharedTransitionScope, animatedContentScope ->
|
||||
MessageItem(
|
||||
node = NodePreviewParameterProvider().values.first(),
|
||||
messageText = stringResource(R.string.sample_message),
|
||||
messageTime = "10:00",
|
||||
messageStatus = MessageStatus.DELIVERED,
|
||||
selected = false,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope,
|
||||
isConnected = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
package com.geeksville.mesh.ui.message.components
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -48,13 +51,13 @@ import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.DataPacket
|
||||
import com.geeksville.mesh.MessageStatus
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.database.entity.Reaction
|
||||
import com.geeksville.mesh.model.Message
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.ui.components.NodeMenu
|
||||
import com.geeksville.mesh.ui.components.NodeMenuAction
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
@@ -105,6 +108,7 @@ fun DeliveryInfo(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
internal fun MessageList(
|
||||
@@ -113,9 +117,11 @@ internal fun MessageList(
|
||||
selectedIds: MutableState<Set<Long>>,
|
||||
onUnreadChanged: (Long) -> Unit,
|
||||
onSendReaction: (String, Int) -> Unit,
|
||||
onNodeMenuAction: (NodeMenuAction) -> Unit = {},
|
||||
onNodeMenuAction: (NodeMenuAction) -> Unit,
|
||||
viewModel: UIViewModel,
|
||||
contactKey: String
|
||||
contactKey: String,
|
||||
sharedTransitionScope: SharedTransitionScope,
|
||||
animatedContentScope: AnimatedContentScope,
|
||||
) {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } }
|
||||
@@ -155,6 +161,9 @@ internal fun MessageList(
|
||||
value += uuid
|
||||
}
|
||||
|
||||
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
|
||||
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle(false)
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
@@ -163,12 +172,16 @@ internal fun MessageList(
|
||||
items(messages, key = { it.uuid }) { msg ->
|
||||
val fromLocal = msg.node.user.id == DataPacket.ID_LOCAL
|
||||
val selected by remember { derivedStateOf { selectedIds.value.contains(msg.uuid) } }
|
||||
|
||||
var node by remember {
|
||||
mutableStateOf(nodes.find { it.num == msg.node.num } ?: msg.node)
|
||||
}
|
||||
LaunchedEffect(nodes) {
|
||||
node = nodes.find { it.num == msg.node.num } ?: msg.node
|
||||
}
|
||||
ReactionRow(fromLocal, msg.emojis) { showReactionDialog = msg.emojis }
|
||||
Box(Modifier.wrapContentSize(Alignment.TopStart)) {
|
||||
var expandedNodeMenu by remember { mutableStateOf(false) }
|
||||
MessageItem(
|
||||
node = msg.node,
|
||||
node = node,
|
||||
messageText = msg.text,
|
||||
messageTime = msg.time,
|
||||
messageStatus = msg.status,
|
||||
@@ -178,20 +191,12 @@ internal fun MessageList(
|
||||
selectedIds.toggle(msg.uuid)
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
},
|
||||
onChipClick = {
|
||||
if (msg.node.num != 0) {
|
||||
expandedNodeMenu = true
|
||||
}
|
||||
},
|
||||
onAction = onNodeMenuAction,
|
||||
onStatusClick = { showStatusDialog = msg },
|
||||
onSendReaction = { onSendReaction(it, msg.packetId) },
|
||||
)
|
||||
NodeMenu(
|
||||
node = msg.node,
|
||||
showFullMenu = true,
|
||||
onDismissRequest = { expandedNodeMenu = false },
|
||||
expanded = expandedNodeMenu,
|
||||
onAction = onNodeMenuAction,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope,
|
||||
isConnected = isConnected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +302,7 @@ fun ChannelSettingsItemList(
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.manuel_position_request),
|
||||
text = stringResource(R.string.manual_position_request),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
|
||||
Submodule app/src/main/proto updated: 91484534a5...0b32ce24f0
@@ -613,7 +613,7 @@
|
||||
<string name="primary_channel_feature">Periodic position and telemetry broadcast</string>
|
||||
<string name="secondary">Secondary</string>
|
||||
<string name="secondary_no_telemetry">No periodic telemetry broadcast</string>
|
||||
<string name="manuel_position_request">Manual position request required</string>
|
||||
<string name="manual_position_request">Manual position request required</string>
|
||||
<string name="press_and_drag">Press and drag to reorder</string>
|
||||
<string name="set_region">Set Region</string>
|
||||
<string name="unmute">Unmute</string>
|
||||
|
||||
Reference in New Issue
Block a user