Expanding node info cards (#1144)

This commit is contained in:
James Rich 2024-07-28 05:04:50 -05:00 committed by GitHub
parent ed17ae0734
commit fc2559f5f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 268 additions and 236 deletions

View file

@ -193,7 +193,6 @@ dependencies {
def composeBom = platform('androidx.compose:compose-bom:2024.06.00')
implementation composeBom
androidTestImplementation composeBom
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.1"
implementation 'androidx.compose.material:material'
implementation 'androidx.activity:activity-compose'

View file

@ -12,16 +12,16 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.*
import com.geeksville.mesh.ChannelProtos.ChannelSettings
import com.geeksville.mesh.ConfigProtos.Config
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.QuickChatActionRepository
import com.geeksville.mesh.database.entity.QuickChatAction
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.PacketRepository
import com.geeksville.mesh.database.QuickChatActionRepository
import com.geeksville.mesh.database.entity.QuickChatAction
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.service.MeshService
@ -29,10 +29,10 @@ import com.geeksville.mesh.util.positionToMeter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
@ -102,10 +102,11 @@ data class NodesUiState(
val sort: NodeSortOption = NodeSortOption.LAST_HEARD,
val filter: String = "",
val includeUnknown: Boolean = false,
val gpsFormat:Int = 0,
val distanceUnits:Int = 0,
val tempInFahrenheit:Boolean = false,
val gpsFormat: Int = 0,
val distanceUnits: Int = 0,
val tempInFahrenheit: Boolean = false,
val ignoreIncomingList: List<Int> = emptyList(),
val showDetails: Boolean = false,
) {
companion object {
val Empty = NodesUiState()
@ -151,11 +152,16 @@ class UIViewModel @Inject constructor(
private val nodeFilterText = MutableStateFlow("")
private val nodeSortOption = MutableStateFlow(NodeSortOption.LAST_HEARD)
private val includeUnknown = MutableStateFlow(false)
private val showDetails = MutableStateFlow(false)
fun setSortOption(sort: NodeSortOption) {
nodeSortOption.value = sort
}
fun toggleShowDetails() {
showDetails.value = !showDetails.value
}
fun toggleIncludeUnknown() {
includeUnknown.value = !includeUnknown.value
}
@ -164,8 +170,9 @@ class UIViewModel @Inject constructor(
nodeFilterText,
nodeSortOption,
includeUnknown,
showDetails,
radioConfigRepository.deviceProfileFlow,
) { filter, sort, includeUnknown, profile ->
) { filter, sort, includeUnknown, showDetails, profile ->
NodesUiState(
sort = sort,
filter = filter,
@ -174,6 +181,7 @@ class UIViewModel @Inject constructor(
distanceUnits = profile.config.display.units.number,
tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit,
ignoreIncomingList = profile.config.lora.ignoreIncomingList,
showDetails = showDetails,
)
}.stateIn(
scope = viewModelScope,

View file

@ -1,11 +1,15 @@
package com.geeksville.mesh.ui
import android.content.ActivityNotFoundException
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
@ -23,9 +27,10 @@ import com.geeksville.mesh.ui.theme.AppTheme
import com.geeksville.mesh.ui.theme.HyperlinkBlue
import java.net.URLEncoder
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LinkedCoordinates(
modifier : Modifier = Modifier,
modifier: Modifier = Modifier,
position: Position?,
format: Int,
nodeName: String?
@ -52,20 +57,28 @@ fun LinkedCoordinates(
}
pop()
}
ClickableText(
modifier = modifier,
text = annotatedString,
onClick = { offset ->
debug("Clicked on link")
annotatedString.getStringAnnotations(tag = "gps", start = offset, end = offset)
.firstOrNull()?.let {
val clipboardManager: ClipboardManager = LocalClipboardManager.current
Text(
modifier = modifier.combinedClickable(
onClick = {
annotatedString.getStringAnnotations(
tag = "gps",
start = 0,
end = annotatedString.length
).firstOrNull()?.let {
try {
uriHandler.openUri(it.item)
} catch (ex: ActivityNotFoundException) {
debug("No application found: $ex")
}
}
}
},
onLongClick = {
clipboardManager.setText(annotatedString)
debug("Copied to clipboard")
}
),
text = annotatedString
)
} else {
// Placeholder for ConstraintLayoutReference; renders no visible content
@ -99,7 +112,7 @@ fun LinkedCoordinatesPreview(
}
}
class GPSFormatPreviewParameterProvider: PreviewParameterProvider<Int> {
class GPSFormatPreviewParameterProvider : PreviewParameterProvider<Int> {
override val values: Sequence<Int>
get() = sequenceOf(0, 1, 2)
}

View file

@ -1,3 +1,12 @@
@file:Suppress(
"FunctionNaming",
"LongMethod",
"LongParameterList",
"DestructuringDeclarationWithTooManyEntries",
"MagicNumber",
"CyclomaticComplexMethod",
)
package com.geeksville.mesh.ui
import androidx.compose.animation.animateColorAsState
@ -6,15 +15,21 @@ import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.repeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Card
import androidx.compose.material.Chip
import androidx.compose.material.ChipDefaults
import androidx.compose.material.Divider
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
@ -22,6 +37,9 @@ import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.res.stringResource
@ -32,8 +50,6 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.NodeInfo
@ -53,8 +69,9 @@ fun NodeInfo(
distanceUnits: Int,
tempInFahrenheit: Boolean,
isIgnored: Boolean = false,
onClicked: () -> Unit = {},
chipClicked: () -> Unit = {},
blinking: Boolean = false,
expanded: Boolean = false,
) {
val unknownShortName = stringResource(id = R.string.unknown_node_short_name)
val unknownLongName = stringResource(id = R.string.unknown_username)
@ -64,6 +81,9 @@ fun NodeInfo(
val distance = thisNodeInfo?.distanceStr(thatNodeInfo, distanceUnits)
val (textColor, nodeColor) = thatNodeInfo.colors
val position = thatNodeInfo.position
val hwInfoString = thatNodeInfo.user?.hwModelString
val highlight = Color(0x33FFFFFF)
val bgColor by animateColorAsState(
targetValue = if (blinking) highlight else Color.Transparent,
@ -74,222 +94,167 @@ fun NodeInfo(
easing = FastOutSlowInEasing
),
repeatMode = RepeatMode.Reverse
), label = "blinking node"
),
label = "blinking node"
)
val style = if (thatNodeInfo.user?.hwModel == MeshProtos.HardwareModel.UNSET) {
LocalTextStyle.current.copy(fontStyle = FontStyle.Italic)
} else {
LocalTextStyle.current
}
val (detailsShown, showDetails) = remember { mutableStateOf(expanded) }
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.defaultMinSize(minHeight = 80.dp)
.defaultMinSize(minHeight = 80.dp),
onClick = { showDetails(!detailsShown) },
) {
Surface {
ConstraintLayout(
modifier = Modifier
.fillMaxWidth()
.background(bgColor)
.padding(8.dp)
) {
val (chip, dist, name, hw, pos, alt, sats, batt, heard, sig, env) = createRefs()
val barrierBattHeard = createStartBarrier(batt, heard)
val sigBarrier = createBottomBarrier(pos, heard)
Box(
// removes the extra spacing above the chip
SelectionContainer {
Column(
modifier = Modifier
.height(32.dp)
.constrainAs(chip) {
top.linkTo(parent.top)
start.linkTo(parent.start)
}
.fillMaxWidth()
.padding(8.dp)
.background(bgColor)
) {
Chip(
modifier = Modifier.width(72.dp),
onClick = onClicked,
colors = ChipDefaults.chipColors(
backgroundColor = Color(nodeColor),
contentColor = Color(textColor)
),
content = {
Text(
modifier = Modifier.fillMaxWidth(),
text = thatNodeInfo.user?.shortName ?: unknownShortName,
fontWeight = FontWeight.Normal,
fontSize = MaterialTheme.typography.button.fontSize,
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
textAlign = TextAlign.Center,
)
},
)
}
if (distance != null) {
Text(
modifier = Modifier.constrainAs(dist) {
top.linkTo(chip.bottom, 8.dp)
start.linkTo(chip.start)
end.linkTo(chip.end)
},
text = distance,
fontSize = MaterialTheme.typography.button.fontSize,
)
}
val style = if (thatNodeInfo.user?.hwModel == MeshProtos.HardwareModel.UNSET) {
LocalTextStyle.current.copy(fontStyle = FontStyle.Italic)
} else {
LocalTextStyle.current
}
Text(
modifier = Modifier.constrainAs(name) {
top.linkTo(parent.top)
linkTo(
start = chip.end,
end = barrierBattHeard,
bias = 0F,
startMargin = 8.dp,
endMargin = 8.dp,
)
width = Dimension.preferredWrapContent
},
text = nodeName,
style = style,
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
)
val hwInfoString = thatNodeInfo.user?.hwModelString
if (hwInfoString != null){
Text(
modifier = Modifier.constrainAs(hw) {
linkTo(
top = name.bottom,
bottom = pos.top,
bias = 0F,
topMargin = 4.dp,
bottomMargin = 4.dp
)
linkTo(
start = name.start,
end = barrierBattHeard,
bias = 0F,
endMargin = 8.dp
)
width = Dimension.preferredWrapContent
},
text = hwInfoString,
fontSize = MaterialTheme.typography.caption.fontSize,
style = style,
)
}
val position = thatNodeInfo.position
LinkedCoordinates(
modifier = Modifier.constrainAs(pos) {
linkTo(
top = hw.bottom,
bottom = sig.top,
bias = 0F,
topMargin = 4.dp,
bottomMargin = 4.dp
)
linkTo(
start = name.start,
end = barrierBattHeard,
bias = 0F,
endMargin = 8.dp
)
width = Dimension.preferredWrapContent
},
position = position,
format = gpsFormat,
nodeName = nodeName
)
val signalShown = signalInfo(
modifier = Modifier.constrainAs(sig) {
top.linkTo(sigBarrier, 4.dp)
bottom.linkTo(env.top, 4.dp)
end.linkTo(parent.end)
},
nodeInfo = thatNodeInfo,
isThisNode = isThisNode
)
if (position?.isValid() == true) {
val system = ConfigProtos.Config.DisplayConfig.DisplayUnits.forNumber(distanceUnits)
val altitude = position.altitude.metersIn(system)
val elevationSuffix = stringResource(id = R.string.elevation_suffix)
ElevationInfo(
modifier = Modifier.constrainAs(alt) {
top.linkTo(pos.bottom, 4.dp)
if (signalShown) {
baseline.linkTo(sig.baseline)
}
linkTo(
start = pos.start,
end = sig.start,
endMargin = 8.dp,
bias = 0F,
)
width = Dimension.preferredWrapContent
},
altitude = altitude,
system = system,
suffix = elevationSuffix
)
val satCount = position.satellitesInView
if (satCount > 0) {
SatelliteCountInfo(
modifier = Modifier.constrainAs(sats) {
top.linkTo(alt.bottom, 4.dp)
linkTo(
start = pos.start,
end = env.start,
endMargin = 8.dp,
bias = 0F,
Row(
modifier = Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Chip(
modifier = Modifier
.width(72.dp)
.padding(end = 8.dp)
.defaultMinSize(minHeight = 32.dp),
colors = ChipDefaults.chipColors(
backgroundColor = Color(nodeColor),
contentColor = Color(textColor)
),
onClick = { chipClicked() },
content = {
Text(
modifier = Modifier.fillMaxWidth(),
text = thatNodeInfo.user?.shortName ?: unknownShortName,
fontWeight = FontWeight.Normal,
fontSize = MaterialTheme.typography.button.fontSize,
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
textAlign = TextAlign.Center,
)
width = Dimension.preferredWrapContent
},
satCount = satCount
)
Text(
modifier = Modifier.weight(1f),
text = nodeName,
style = style,
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
softWrap = true,
)
LastHeardInfo(
lastHeard = thatNodeInfo.lastHeard
)
}
}
BatteryInfo(
modifier = Modifier.constrainAs(batt) {
top.linkTo(parent.top)
end.linkTo(parent.end)
},
batteryLevel = thatNodeInfo.batteryLevel,
voltage = thatNodeInfo.voltage
)
LastHeardInfo(
modifier = Modifier.constrainAs(heard) {
top.linkTo(batt.bottom, 4.dp)
end.linkTo(parent.end)
},
lastHeard = thatNodeInfo.lastHeard
)
val envMetrics = thatNodeInfo.environmentMetrics
?.getDisplayString(tempInFahrenheit) ?: ""
if (envMetrics.isNotBlank()) {
Text(
modifier = Modifier.constrainAs(env) {
if (signalShown) {
top.linkTo(sig.bottom, 4.dp)
} else {
top.linkTo(pos.bottom, 4.dp)
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
if (distance != null) {
Text(
text = distance,
fontSize = MaterialTheme.typography.button.fontSize,
)
} else {
Spacer(modifier = Modifier.width(16.dp))
}
BatteryInfo(
batteryLevel = thatNodeInfo.batteryLevel,
voltage = thatNodeInfo.voltage
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
signalInfo(
nodeInfo = thatNodeInfo,
isThisNode = isThisNode
)
if (position?.isValid() == true) {
val satCount = position.satellitesInView
if (satCount > 0) {
SatelliteCountInfo(
satCount = satCount
)
}
end.linkTo(parent.end)
},
text = envMetrics,
color = MaterialTheme.colors.onSurface,
fontSize = MaterialTheme.typography.button.fontSize
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
thatNodeInfo.environmentMetrics?.getDisplayString(tempInFahrenheit)?.let { envMetrics ->
Text(
text = envMetrics,
color = MaterialTheme.colors.onSurface,
fontSize = MaterialTheme.typography.button.fontSize
)
}
}
if (detailsShown || expanded) {
Spacer(modifier = Modifier.height(8.dp))
Divider()
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
DisableSelection {
LinkedCoordinates(
position = position,
format = gpsFormat,
nodeName = nodeName
)
}
val system =
ConfigProtos.Config.DisplayConfig.DisplayUnits.forNumber(distanceUnits)
if (position?.isValid() == true) {
val altitude = position.altitude.metersIn(system)
val elevationSuffix = stringResource(id = R.string.elevation_suffix)
ElevationInfo(
altitude = altitude,
system = system,
suffix = elevationSuffix
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
if (hwInfoString != null) {
Text(
text = "$hwInfoString",
fontSize = MaterialTheme.typography.button.fontSize,
style = style,
)
}
val nodeId = thatNodeInfo.user?.id
if (nodeId != null) {
Text(text = nodeId, fontSize = MaterialTheme.typography.button.fontSize)
}
}
}
}
}
}
@ -323,12 +288,31 @@ fun NodeInfoPreview(
) {
AppTheme {
val thisNodeInfo = NodeInfoPreviewParameterProvider().values.first()
NodeInfo(
thisNodeInfo,
thatNodeInfo,
0,
1,
true
)
Column {
Text(
text = "Details Collapsed",
color = MaterialTheme.colors.onBackground
)
NodeInfo(
thisNodeInfo = thisNodeInfo,
thatNodeInfo = thatNodeInfo,
gpsFormat = 0,
distanceUnits = 1,
tempInFahrenheit = true,
expanded = false
)
Text(
text = "Details Shown",
color = MaterialTheme.colors.onBackground
)
NodeInfo(
thisNodeInfo = thisNodeInfo,
thatNodeInfo = thatNodeInfo,
gpsFormat = 0,
distanceUnits = 1,
tempInFahrenheit = true,
expanded = true
)
}
}
}

View file

@ -28,12 +28,15 @@ import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.model.NodeSortOption
@Suppress("LongMethod")
@Composable
internal fun NodeSortButton(
currentSortOption: NodeSortOption,
onSortSelected: (NodeSortOption) -> Unit,
includeUnknown: Boolean,
onToggleIncludeUnknown: () -> Unit,
showDetails: Boolean,
onToggleShowDetails: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier) {
@ -84,6 +87,24 @@ internal fun NodeSortButton(
)
}
}
Divider()
DropdownMenuItem(
onClick = {
onToggleShowDetails()
expanded = false
},
) {
Text(
text = stringResource(id = R.string.node_filter_show_details),
)
AnimatedVisibility(visible = showDetails) {
Icon(
imageVector = Icons.Default.Done,
contentDescription = null,
modifier = Modifier.padding(start = 4.dp),
)
}
}
}
}
}

View file

@ -96,6 +96,7 @@ class UsersFragment : ScreenFragment("Users"), Logging {
parentFragmentManager.navigateToRadioConfig(node.num)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@ -105,7 +106,7 @@ class UsersFragment : ScreenFragment("Users"), Logging {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppTheme {
NodesScreen(model = model, onClick = ::popup)
NodesScreen(model = model, chipClicked = ::popup)
}
}
}
@ -116,7 +117,7 @@ class UsersFragment : ScreenFragment("Users"), Logging {
@Composable
fun NodesScreen(
model: UIViewModel = hiltViewModel(),
onClick: (NodeInfo) -> Unit,
chipClicked: (NodeInfo) -> Unit,
) {
val state by model.nodesUiState.collectAsStateWithLifecycle()
@ -156,6 +157,8 @@ fun NodesScreen(
onSortSelected = model::setSortOption,
includeUnknown = state.includeUnknown,
onToggleIncludeUnknown = model::toggleIncludeUnknown,
showDetails = state.showDetails,
onToggleShowDetails = model::toggleShowDetails,
)
}
}
@ -168,8 +171,9 @@ fun NodesScreen(
distanceUnits = state.distanceUnits,
tempInFahrenheit = state.tempInFahrenheit,
isIgnored = state.ignoreIncomingList.contains(node.num),
onClicked = { onClick(node) },
chipClicked = { chipClicked(node) },
blinking = node == focusedNode,
expanded = state.showDetails
)
}
}

View file

@ -19,6 +19,7 @@ class NodeInfoPreviewParameterProvider: PreviewParameterProvider<NodeInfo> {
latitude = 33.812511,
longitude = -117.918976,
altitude = 138,
satellitesInView = 4,
),
lastHeard = currentTime(),
channel = 0,
@ -60,6 +61,7 @@ class NodeInfoPreviewParameterProvider: PreviewParameterProvider<NodeInfo> {
latitude = 33.80523471893125,
longitude = -117.92084605996297,
altitude = 121,
satellitesInView = 66,
),
lastHeard = currentTime() - 300,
channel = 0,

View file

@ -17,6 +17,7 @@
<string name="node_filter_placeholder">Filter</string>
<string name="desc_node_filter_clear">clear node filter</string>
<string name="node_filter_include_unknown">Include unknown</string>
<string name="node_filter_show_details">Show details</string>
<string name="node_sort_alpha">A-Z</string>
<string name="node_sort_channel">Channel</string>
<string name="node_sort_distance">Distance</string>