From 6be44675e272311367e12f34696dddc4079d951a Mon Sep 17 00:00:00 2001 From: andrekir Date: Fri, 18 Oct 2024 19:27:15 -0300 Subject: [PATCH] feat: add `NodeDetailsScreen` with metrics and remote admin navigation --- .../geeksville/mesh/model/MetricsViewModel.kt | 81 ++----- .../mesh/ui/DeviceSettingsFragment.kt | 54 ++++- .../com/geeksville/mesh/ui/MetricsFragment.kt | 200 ---------------- .../geeksville/mesh/ui/NodeDetailsScreen.kt | 218 ++++++++++++++++++ .../java/com/geeksville/mesh/ui/NodeMenu.kt | 2 - .../com/geeksville/mesh/ui/UsersFragment.kt | 16 +- .../mesh/ui/components/PreferenceCategory.kt | 28 ++- .../drawable/baseline_charging_station_24.xml | 10 - .../res/drawable/baseline_thermostat_24.xml | 10 - app/src/main/res/menu/menu_nodes.xml | 10 +- app/src/main/res/values/strings.xml | 1 + 11 files changed, 322 insertions(+), 308 deletions(-) delete mode 100644 app/src/main/java/com/geeksville/mesh/ui/MetricsFragment.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt delete mode 100644 app/src/main/res/drawable/baseline_charging_station_24.xml delete mode 100644 app/src/main/res/drawable/baseline_thermostat_24.xml diff --git a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt index f5f2531d3..c58082b94 100644 --- a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt @@ -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.entries, - val isLoading: Boolean = false, val deviceMetrics: List = emptyList(), val environmentMetrics: List = 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>(emptyList()) - private val _environmentMetrics = MutableStateFlow>(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() - val environmentList = mutableListOf() - 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 } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt index e7d478c89..0495d19be 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/DeviceSettingsFragment.kt @@ -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, diff --git a/app/src/main/java/com/geeksville/mesh/ui/MetricsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MetricsFragment.kt deleted file mode 100644 index db73f1695..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/MetricsFragment.kt +++ /dev/null @@ -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 }), - ) - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt new file mode 100644 index 000000000..7294a0228 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeMenu.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeMenu.kt index a1d4a95d7..42b0bf600 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeMenu.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeMenu.kt @@ -12,7 +12,6 @@ internal fun View.nodeMenu( node: NodeEntity, ignoreIncomingList: List, 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 diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt index 9ddd9d9fd..fc647062e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -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( diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/PreferenceCategory.kt b/app/src/main/java/com/geeksville/mesh/ui/components/PreferenceCategory.kt index 8851c99c1..8187a4d1c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/PreferenceCategory.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/PreferenceCategory.kt @@ -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) diff --git a/app/src/main/res/drawable/baseline_charging_station_24.xml b/app/src/main/res/drawable/baseline_charging_station_24.xml deleted file mode 100644 index 5efc33810..000000000 --- a/app/src/main/res/drawable/baseline_charging_station_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/baseline_thermostat_24.xml b/app/src/main/res/drawable/baseline_thermostat_24.xml deleted file mode 100644 index 79f663280..000000000 --- a/app/src/main/res/drawable/baseline_thermostat_24.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/menu/menu_nodes.xml b/app/src/main/res/menu/menu_nodes.xml index 3de66d8d3..cc3a0753c 100644 --- a/app/src/main/res/menu/menu_nodes.xml +++ b/app/src/main/res/menu/menu_nodes.xml @@ -30,14 +30,8 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3576597ac..c404d5f6d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -272,4 +272,5 @@ 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. Request user info New nodes notifications + More details