feat: add `NodeDetailsScreen` with metrics and remote admin navigation

pull/1335/head
andrekir 2024-10-18 19:27:15 -03:00 zatwierdzone przez Andre K
rodzic b73c53bc11
commit 6be44675e2
11 zmienionych plików z 322 dodań i 308 usunięć

Wyświetl plik

@ -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
}
}

Wyświetl plik

@ -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,

Wyświetl plik

@ -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 }),
)
}
}

Wyświetl plik

@ -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)
}
}

Wyświetl plik

@ -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

Wyświetl plik

@ -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(

Wyświetl plik

@ -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)

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>