kopia lustrzana https://github.com/meshtastic/Meshtastic-Android
feat: add `NodeDetailsScreen` with metrics and remote admin navigation
rodzic
b73c53bc11
commit
6be44675e2
|
@ -1,37 +1,27 @@
|
|||
package com.geeksville.mesh.model
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.TelemetryProtos.Telemetry
|
||||
import com.geeksville.mesh.database.MeshLogRepository
|
||||
import com.geeksville.mesh.repository.datastore.RadioConfigRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.enums.EnumEntries
|
||||
|
||||
enum class MetricsPage(
|
||||
@StringRes val titleResId: Int,
|
||||
@DrawableRes val drawableResId: Int,
|
||||
) {
|
||||
DEVICE(R.string.device, R.drawable.baseline_charging_station_24),
|
||||
ENVIRONMENT(R.string.environment, R.drawable.baseline_thermostat_24),
|
||||
}
|
||||
|
||||
data class MetricsState(
|
||||
val pages: EnumEntries<MetricsPage> = MetricsPage.entries,
|
||||
val isLoading: Boolean = false,
|
||||
val deviceMetrics: List<Telemetry> = emptyList(),
|
||||
val environmentMetrics: List<Telemetry> = emptyList(),
|
||||
val environmentDisplayFahrenheit: Boolean = false,
|
||||
) {
|
||||
fun hasDeviceMetrics() = deviceMetrics.isNotEmpty()
|
||||
fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty()
|
||||
|
||||
companion object {
|
||||
val Empty = MetricsState()
|
||||
}
|
||||
|
@ -39,60 +29,35 @@ data class MetricsState(
|
|||
|
||||
@HiltViewModel
|
||||
class MetricsViewModel @Inject constructor(
|
||||
val nodeDB: NodeDB,
|
||||
private val meshLogRepository: MeshLogRepository,
|
||||
meshLogRepository: MeshLogRepository,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
) : ViewModel() {
|
||||
private val destNum = MutableStateFlow(0)
|
||||
|
||||
private val isLoading = MutableStateFlow(false)
|
||||
private val _deviceMetrics = MutableStateFlow<List<Telemetry>>(emptyList())
|
||||
private val _environmentMetrics = MutableStateFlow<List<Telemetry>>(emptyList())
|
||||
|
||||
val state = combine(
|
||||
isLoading,
|
||||
_deviceMetrics,
|
||||
_environmentMetrics,
|
||||
radioConfigRepository.deviceProfileFlow,
|
||||
) { isLoading, device, environment, profile ->
|
||||
MetricsState(
|
||||
isLoading = isLoading,
|
||||
deviceMetrics = device,
|
||||
environmentMetrics = environment,
|
||||
environmentDisplayFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit,
|
||||
)
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val state = destNum.flatMapLatest { destNum ->
|
||||
combine(
|
||||
meshLogRepository.getTelemetryFrom(destNum),
|
||||
radioConfigRepository.moduleConfigFlow,
|
||||
) { telemetry, config ->
|
||||
MetricsState(
|
||||
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
|
||||
environmentMetrics = telemetry.filter {
|
||||
it.hasEnvironmentMetrics() && it.environmentMetrics.relativeHumidity >= 0f
|
||||
},
|
||||
environmentDisplayFahrenheit = config.telemetry.environmentDisplayFahrenheit,
|
||||
)
|
||||
}
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = WhileSubscribed(5_000),
|
||||
started = WhileSubscribed(),
|
||||
initialValue = MetricsState.Empty,
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets the short name of the node identified by `nodeNum`.
|
||||
*/
|
||||
fun getNodeName(nodeNum: Int): String? = nodeDB.nodeDBbyNum.value[nodeNum]?.user?.shortName
|
||||
|
||||
/**
|
||||
* Used to set the Node for which the user will see charts for.
|
||||
*/
|
||||
fun setSelectedNode(nodeNum: Int) {
|
||||
viewModelScope.launch {
|
||||
isLoading.value = true
|
||||
meshLogRepository.getTelemetryFrom(nodeNum).collect {
|
||||
val deviceList = mutableListOf<Telemetry>()
|
||||
val environmentList = mutableListOf<Telemetry>()
|
||||
for (telemetry in it) {
|
||||
if (telemetry.hasDeviceMetrics()) {
|
||||
deviceList.add(telemetry)
|
||||
}
|
||||
/* Avoiding negative outliers */
|
||||
if (telemetry.hasEnvironmentMetrics() && telemetry.environmentMetrics.relativeHumidity >= 0f) {
|
||||
environmentList.add(telemetry)
|
||||
}
|
||||
}
|
||||
_deviceMetrics.value = deviceList
|
||||
_environmentMetrics.value = environmentList
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
destNum.value = nodeNum
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,8 @@ import androidx.compose.foundation.layout.Spacer
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
|
@ -44,6 +46,7 @@ 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.vector.ImageVector
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
@ -66,10 +69,13 @@ import com.geeksville.mesh.android.Logging
|
|||
import com.geeksville.mesh.config
|
||||
import com.geeksville.mesh.database.entity.NodeEntity
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.model.RadioConfigState
|
||||
import com.geeksville.mesh.model.RadioConfigViewModel
|
||||
import com.geeksville.mesh.moduleConfig
|
||||
import com.geeksville.mesh.service.MeshService.ConnectionState
|
||||
import com.geeksville.mesh.ui.components.DeviceMetricsScreen
|
||||
import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen
|
||||
import com.geeksville.mesh.ui.components.PreferenceCategory
|
||||
import com.geeksville.mesh.ui.components.config.AmbientLightingConfigItemList
|
||||
import com.geeksville.mesh.ui.components.config.AudioConfigItemList
|
||||
|
@ -99,9 +105,12 @@ import com.geeksville.mesh.ui.components.config.UserConfigItemList
|
|||
import com.google.accompanist.themeadapter.appcompat.AppCompatTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
internal fun FragmentManager.navigateToRadioConfig(destNum: Int? = null) {
|
||||
internal fun FragmentManager.navigateToRadioConfig(
|
||||
destNum: Int? = null,
|
||||
startDestination: String = "RadioConfig",
|
||||
) {
|
||||
val radioConfigFragment = DeviceSettingsFragment().apply {
|
||||
arguments = bundleOf("destNum" to destNum)
|
||||
arguments = bundleOf("destNum" to destNum, "startDestination" to startDestination)
|
||||
}
|
||||
beginTransaction()
|
||||
.replace(R.id.mainActivityLayout, radioConfigFragment)
|
||||
|
@ -120,6 +129,7 @@ class DeviceSettingsFragment : ScreenFragment("Radio Configuration"), Logging {
|
|||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val destNum = arguments?.getInt("destNum")
|
||||
val startDestination = arguments?.getString("startDestination") ?: "RadioConfig"
|
||||
model.setDestNum(destNum)
|
||||
|
||||
return ComposeView(requireContext()).apply {
|
||||
|
@ -159,6 +169,7 @@ class DeviceSettingsFragment : ScreenFragment("Radio Configuration"), Logging {
|
|||
node = node,
|
||||
viewModel = model,
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
|
@ -247,6 +258,7 @@ fun RadioConfigNavHost(
|
|||
node: NodeEntity?,
|
||||
viewModel: RadioConfigViewModel = hiltViewModel(),
|
||||
navController: NavHostController = rememberNavController(),
|
||||
startDestination: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
|
@ -254,12 +266,32 @@ fun RadioConfigNavHost(
|
|||
|
||||
val radioConfigState by viewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||
|
||||
val metricsViewModel: MetricsViewModel = hiltViewModel()
|
||||
val metricsState by metricsViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "home",
|
||||
startDestination = startDestination,
|
||||
modifier = modifier,
|
||||
) {
|
||||
composable("home") {
|
||||
composable("NodeDetails") {
|
||||
NodeDetailsScreen(
|
||||
node = node,
|
||||
metricsState = metricsState,
|
||||
onNavigate = { navController.navigate(route = it) },
|
||||
setSelectedNode = metricsViewModel::setSelectedNode,
|
||||
)
|
||||
}
|
||||
composable("DeviceMetrics") {
|
||||
DeviceMetricsScreen(metricsState.deviceMetrics)
|
||||
}
|
||||
composable("EnvironmentMetrics") {
|
||||
EnvironmentMetricsScreen(
|
||||
metricsState.environmentMetrics,
|
||||
metricsState.environmentDisplayFahrenheit,
|
||||
)
|
||||
}
|
||||
composable("RadioConfig") {
|
||||
RadioConfigScreen(
|
||||
node = node,
|
||||
connected = connected,
|
||||
|
@ -635,9 +667,10 @@ fun RadioConfigScreen(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun NavCard(
|
||||
fun NavCard(
|
||||
title: String,
|
||||
enabled: Boolean,
|
||||
icon: ImageVector? = null,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val color = if (enabled) {
|
||||
|
@ -655,8 +688,17 @@ private fun NavCard(
|
|||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 12.dp, horizontal = 12.dp)
|
||||
modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp)
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = color,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.body1,
|
||||
|
|
|
@ -1,200 +0,0 @@
|
|||
package com.geeksville.mesh.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.Tab
|
||||
import androidx.compose.material.TabRow
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.android.Logging
|
||||
import com.geeksville.mesh.model.MetricsPage
|
||||
import com.geeksville.mesh.model.MetricsState
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.ui.components.DeviceMetricsScreen
|
||||
import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen
|
||||
import com.geeksville.mesh.ui.theme.AppTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal fun FragmentManager.navigateToMetrics(nodeNum: Int? = null) {
|
||||
val metricsFragment = MetricsFragment().apply {
|
||||
arguments = bundleOf("nodeNum" to nodeNum)
|
||||
}
|
||||
beginTransaction()
|
||||
.replace(R.id.mainActivityLayout, metricsFragment)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MetricsFragment : ScreenFragment("Metrics"), Logging {
|
||||
|
||||
private val model: MetricsViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val nodeNum = arguments?.getInt("nodeNum")
|
||||
if (nodeNum != null) {
|
||||
model.setSelectedNode(nodeNum)
|
||||
}
|
||||
|
||||
val nodeName = model.getNodeName(nodeNum ?: 0)
|
||||
|
||||
return ComposeView(requireContext()).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
AppTheme {
|
||||
MetricsScreen(
|
||||
model = model,
|
||||
nodeName = nodeName,
|
||||
navigateBack = {
|
||||
parentFragmentManager.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MetricsScreen(
|
||||
model: MetricsViewModel = hiltViewModel(),
|
||||
nodeName: String?,
|
||||
navigateBack: () -> Unit,
|
||||
) {
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
val pagerState = rememberPagerState(pageCount = { state.pages.size })
|
||||
|
||||
Scaffold(
|
||||
/*
|
||||
* NOTE: The bottom bar could be used to enable other actions such as clear or export data.
|
||||
*/
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
backgroundColor = colorResource(R.color.toolbarBackground),
|
||||
contentColor = colorResource(R.color.toolbarText),
|
||||
title = {
|
||||
Text(
|
||||
text = "${stringResource(R.string.metrics)}: $nodeName",
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = navigateBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.navigate_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
MetricsPagerScreen(
|
||||
state = state,
|
||||
pagerState = pagerState,
|
||||
modifier = Modifier.padding(top = innerPadding.calculateTopPadding())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MetricsPagerScreen(
|
||||
state: MetricsState,
|
||||
pagerState: PagerState,
|
||||
modifier: Modifier = Modifier,
|
||||
) = with(state) {
|
||||
Column(modifier) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
TabRow(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
) {
|
||||
pages.forEachIndexed { index, page ->
|
||||
val title = stringResource(id = page.titleResId)
|
||||
Tab(
|
||||
selected = pagerState.currentPage == index,
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
||||
text = { Text(text = title) },
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(id = page.drawableResId),
|
||||
contentDescription = title
|
||||
)
|
||||
},
|
||||
unselectedContentColor = MaterialTheme.colors.secondaryVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
verticalAlignment = Alignment.Top,
|
||||
) { index ->
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
when (pages[index]) {
|
||||
MetricsPage.DEVICE -> DeviceMetricsScreen(deviceMetrics)
|
||||
MetricsPage.ENVIRONMENT -> EnvironmentMetricsScreen(
|
||||
environmentMetrics,
|
||||
state.environmentDisplayFahrenheit
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun MetricsPreview() {
|
||||
AppTheme {
|
||||
val state = MetricsState.Empty
|
||||
MetricsPagerScreen(
|
||||
state = state,
|
||||
pagerState = rememberPagerState(pageCount = { state.pages.size }),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,218 @@
|
|||
package com.geeksville.mesh.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChargingStation
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.KeyOff
|
||||
import androidx.compose.material.icons.filled.Numbers
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Thermostat
|
||||
import androidx.compose.material.icons.filled.Work
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.database.entity.NodeEntity
|
||||
import com.geeksville.mesh.model.MetricsState
|
||||
import com.geeksville.mesh.ui.components.PreferenceCategory
|
||||
import com.geeksville.mesh.ui.preview.NodeEntityPreviewParameterProvider
|
||||
import com.geeksville.mesh.ui.theme.AppTheme
|
||||
import com.geeksville.mesh.util.formatAgo
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Composable
|
||||
fun NodeDetailsScreen(
|
||||
node: NodeEntity?,
|
||||
metricsState: MetricsState,
|
||||
modifier: Modifier = Modifier,
|
||||
onNavigate: (String) -> Unit,
|
||||
setSelectedNode: (Int) -> Unit,
|
||||
) {
|
||||
if (node != null) {
|
||||
LaunchedEffect(node.num) {
|
||||
setSelectedNode(node.num)
|
||||
}
|
||||
|
||||
NodeDetailsItemList(
|
||||
node = node,
|
||||
metricsState = metricsState,
|
||||
onNavigate = onNavigate,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun NodeDetailsItemList(
|
||||
node: NodeEntity,
|
||||
metricsState: MetricsState,
|
||||
modifier: Modifier = Modifier,
|
||||
onNavigate: (String) -> Unit = {},
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
) {
|
||||
item {
|
||||
PreferenceCategory("Details") {
|
||||
if (node.mismatchKey) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyOff,
|
||||
contentDescription = stringResource(id = R.string.encryption_error),
|
||||
tint = Color.Red,
|
||||
)
|
||||
Column(modifier = Modifier.padding(start = 8.dp)) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.encryption_error),
|
||||
style = MaterialTheme.typography.h6.copy(color = Color.Red)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.encryption_error_text),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NodeDetailRow(
|
||||
label = "Node Number",
|
||||
icon = Icons.Default.Numbers,
|
||||
value = node.num.toUInt().toString()
|
||||
)
|
||||
|
||||
NodeDetailRow(
|
||||
label = "User Id",
|
||||
icon = Icons.Default.Person,
|
||||
value = node.user.id
|
||||
)
|
||||
|
||||
NodeDetailRow(
|
||||
label = "Role",
|
||||
icon = Icons.Default.Work,
|
||||
value = node.user.role.name
|
||||
)
|
||||
|
||||
if (node.deviceMetrics.uptimeSeconds > 0) {
|
||||
NodeDetailRow(
|
||||
label = "Uptime",
|
||||
icon = Icons.Default.CheckCircle,
|
||||
value = formatUptime(node.deviceMetrics.uptimeSeconds)
|
||||
)
|
||||
}
|
||||
|
||||
NodeDetailRow(
|
||||
label = "Last heard",
|
||||
icon = Icons.Default.History,
|
||||
value = formatAgo(node.lastHeard)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
NavCard(
|
||||
title = "Device Metrics Logs",
|
||||
icon = Icons.Default.ChargingStation,
|
||||
enabled = metricsState.hasDeviceMetrics()
|
||||
) {
|
||||
onNavigate("DeviceMetrics")
|
||||
}
|
||||
|
||||
NavCard(
|
||||
title = "Environment Metrics Logs",
|
||||
icon = Icons.Default.Thermostat,
|
||||
enabled = metricsState.hasEnvironmentMetrics()
|
||||
) {
|
||||
onNavigate("EnvironmentMetrics")
|
||||
}
|
||||
|
||||
NavCard(
|
||||
title = "Remote Administration",
|
||||
icon = Icons.Default.Settings,
|
||||
enabled = !node.user.isLicensed // TODO check for isManaged
|
||||
) {
|
||||
onNavigate("RadioConfig")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NodeDetailRow(label: String, icon: ImageVector, value: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = label,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(label)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatUptime(seconds: Int): String = formatUptime(seconds.toLong())
|
||||
|
||||
private fun formatUptime(seconds: Long): String {
|
||||
val days = TimeUnit.SECONDS.toDays(seconds)
|
||||
val hours = TimeUnit.SECONDS.toHours(seconds) % TimeUnit.DAYS.toHours(1)
|
||||
val minutes = TimeUnit.SECONDS.toMinutes(seconds) % TimeUnit.HOURS.toMinutes(1)
|
||||
val secs = seconds % TimeUnit.MINUTES.toSeconds(1)
|
||||
|
||||
return listOfNotNull(
|
||||
"${days}d".takeIf { days > 0 },
|
||||
"${hours}h".takeIf { hours > 0 },
|
||||
"${minutes}m".takeIf { minutes > 0 },
|
||||
"${secs}s".takeIf { secs > 0 },
|
||||
).joinToString(" ")
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun NodeDetailsPreview(
|
||||
@PreviewParameter(NodeEntityPreviewParameterProvider::class)
|
||||
node: NodeEntity
|
||||
) {
|
||||
AppTheme {
|
||||
NodeDetailsItemList(node, MetricsState.Empty)
|
||||
}
|
||||
}
|
|
@ -12,7 +12,6 @@ internal fun View.nodeMenu(
|
|||
node: NodeEntity,
|
||||
ignoreIncomingList: List<Int>,
|
||||
isOurNode: Boolean = false,
|
||||
isManaged: Boolean = false,
|
||||
onMenuItemAction: MenuItem.() -> Unit,
|
||||
) = PopupMenu(context, this, Gravity.NO_GRAVITY, R.attr.actionOverflowMenuStyle, 0).apply {
|
||||
val isIgnored = ignoreIncomingList.contains(node.num)
|
||||
|
@ -20,7 +19,6 @@ internal fun View.nodeMenu(
|
|||
inflate(R.menu.menu_nodes)
|
||||
menu.apply {
|
||||
setGroupVisible(R.id.group_remote, !isOurNode)
|
||||
setGroupEnabled(R.id.group_admin, !isManaged)
|
||||
findItem(R.id.ignore).apply {
|
||||
isEnabled = isIgnored || ignoreIncomingList.size < 3
|
||||
isChecked = isIgnored
|
||||
|
|
|
@ -46,7 +46,6 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
node = node,
|
||||
ignoreIncomingList = ignoreIncomingList,
|
||||
isOurNode = isOurNode,
|
||||
isManaged = model.isManaged,
|
||||
) {
|
||||
when (itemId) {
|
||||
R.id.direct_message -> {
|
||||
|
@ -77,14 +76,10 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
}
|
||||
}
|
||||
|
||||
R.id.remote_admin -> {
|
||||
R.id.more_details -> {
|
||||
navigateToRadioConfig(node.num)
|
||||
}
|
||||
|
||||
R.id.metrics -> {
|
||||
navigateToMetrics(node.num)
|
||||
}
|
||||
|
||||
R.id.request_userinfo -> {
|
||||
model.requestUserInfo(node.num)
|
||||
}
|
||||
|
@ -101,13 +96,8 @@ class UsersFragment : ScreenFragment("Users"), Logging {
|
|||
}
|
||||
|
||||
private fun navigateToRadioConfig(nodeNum: Int) {
|
||||
info("calling RadioConfig --> destNum: $nodeNum")
|
||||
parentFragmentManager.navigateToRadioConfig(nodeNum)
|
||||
}
|
||||
|
||||
private fun navigateToMetrics(nodeNum: Int) {
|
||||
info("calling Metrics --> destNum: $nodeNum")
|
||||
parentFragmentManager.navigateToMetrics(nodeNum)
|
||||
info("calling NodeDetails --> destNum: $nodeNum")
|
||||
parentFragmentManager.navigateToRadioConfig(nodeNum, "NodeDetails")
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
|
|
|
@ -1,9 +1,16 @@
|
|||
package com.geeksville.mesh.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.ProvideTextStyle
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
@ -11,13 +18,32 @@ import androidx.compose.ui.unit.dp
|
|||
@Composable
|
||||
fun PreferenceCategory(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
content: (@Composable ColumnScope.() -> Unit)? = null
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
modifier = modifier.padding(start = 16.dp, top = 24.dp, bottom = 8.dp, end = 16.dp),
|
||||
style = MaterialTheme.typography.h6,
|
||||
)
|
||||
if (content != null) {
|
||||
Surface(
|
||||
modifier = modifier.padding(bottom = 8.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = 1.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
ProvideTextStyle(MaterialTheme.typography.body1) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="#FFFFFF"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M14.5,11l-3,6v-4h-2l3,-6v4H14.5zM7,1h10c1.1,0 2,0.9 2,2v18c0,1.1 -0.9,2 -2,2H7c-1.1,0 -2,-0.9 -2,-2V3C5,1.9 5.9,1 7,1zM7,6v12h10V6H7z" />
|
||||
</vector>
|
|
@ -1,10 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="#FFFFFF"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M15,13V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v8c-1.21,0.91 -2,2.37 -2,4c0,2.76 2.24,5 5,5s5,-2.24 5,-5C17,15.37 16.21,13.91 15,13zM11,11V5c0,-0.55 0.45,-1 1,-1s1,0.45 1,1v1h-1v1h1v1v1h-1v1h1v1H11z" />
|
||||
</vector>
|
|
@ -30,14 +30,8 @@
|
|||
</group>
|
||||
<group android:id="@+id/group_both">
|
||||
<item
|
||||
android:id="@+id/metrics"
|
||||
android:title="@string/metrics"
|
||||
app:showAsAction="withText" />
|
||||
</group>
|
||||
<group android:id="@+id/group_admin">
|
||||
<item
|
||||
android:id="@+id/remote_admin"
|
||||
android:title="@string/device_settings"
|
||||
android:id="@+id/more_details"
|
||||
android:title="@string/more_details"
|
||||
app:showAsAction="withText" />
|
||||
</group>
|
||||
</menu>
|
|
@ -272,4 +272,5 @@
|
|||
<string name="encryption_error_text">The public key does not match the recorded key. You may remove the node and let it exchange keys again, but this may indicate a more serious security problem. Contact the user through another trusted channel, to determine if the key change was due to a factory reset or other intentional action.</string>
|
||||
<string name="request_userinfo">Request user info</string>
|
||||
<string name="meshtastic_new_nodes_notifications">New nodes notifications</string>
|
||||
<string name="more_details">More details</string>
|
||||
</resources>
|
||||
|
|
Ładowanie…
Reference in New Issue