feat(NodeDetails): add loading state indicator and pager tabs

pull/1224/head
andrekir 2024-09-01 12:03:32 -03:00
rodzic df2847dd6f
commit b17bdd4fb8
3 zmienionych plików z 125 dodań i 66 usunięć

Wyświetl plik

@ -1,15 +1,39 @@
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.enums.EnumEntries
enum class NodeDetailPage(
@StringRes val titleResId: Int,
@DrawableRes val drawableResId: Int,
) {
DEVICE(R.string.device_metrics, R.drawable.baseline_charging_station_24),
ENVIRONMENT(R.string.environment_metrics, R.drawable.baseline_thermostat_24),
}
data class NodeDetailsState(
val pages: EnumEntries<NodeDetailPage> = NodeDetailPage.entries,
val isLoading: Boolean = false,
val deviceMetrics: List<Telemetry> = emptyList(),
val environmentMetrics: List<Telemetry> = emptyList(),
) {
companion object {
val Empty = NodeDetailsState()
}
}
@HiltViewModel
class NodeDetailsViewModel @Inject constructor(
@ -17,11 +41,25 @@ class NodeDetailsViewModel @Inject constructor(
private val meshLogRepository: MeshLogRepository
) : ViewModel() {
private val isLoading = MutableStateFlow(false)
private val _deviceMetrics = MutableStateFlow<List<Telemetry>>(emptyList())
val deviceMetrics: StateFlow<List<Telemetry>> = _deviceMetrics
private val _environmentMetrics = MutableStateFlow<List<Telemetry>>(emptyList())
val environmentMetrics: StateFlow<List<Telemetry>> = _environmentMetrics
val state = combine(
isLoading,
_deviceMetrics,
_environmentMetrics,
) { isLoading, device, environment ->
NodeDetailsState(
isLoading = isLoading,
deviceMetrics = device,
environmentMetrics = environment,
)
}.stateIn(
scope = viewModelScope,
started = WhileSubscribed(5_000),
initialValue = NodeDetailsState.Empty,
)
/**
* Gets the short name of the node identified by `nodeNum`.
@ -33,6 +71,7 @@ class NodeDetailsViewModel @Inject constructor(
*/
fun setSelectedNode(nodeNum: Int) {
viewModelScope.launch {
isLoading.value = true
meshLogRepository.getTelemetryFrom(nodeNum).collect {
val deviceList = mutableListOf<Telemetry>()
val environmentList = mutableListOf<Telemetry>()
@ -45,6 +84,7 @@ class NodeDetailsViewModel @Inject constructor(
}
_deviceMetrics.value = deviceList
_environmentMetrics.value = environmentList
isLoading.value = false
}
}
}

Wyświetl plik

@ -5,32 +5,35 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
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.layout.wrapContentHeight
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
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.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
@ -38,11 +41,14 @@ 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.NodeDetailPage
import com.geeksville.mesh.model.NodeDetailsState
import com.geeksville.mesh.model.NodeDetailsViewModel
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.navigateToNodeDetails(nodeNum: Int? = null) {
val nodeDetailsFragment = NodeDetailsFragment().apply {
@ -94,10 +100,8 @@ fun NodeDetailsScreen(
nodeName: String?,
navigateBack: () -> Unit,
) {
val deviceMetrics by model.deviceMetrics.collectAsStateWithLifecycle()
val environmentMetrics by model.environmentMetrics.collectAsStateWithLifecycle()
val pagerState = rememberPagerState(pageCount = { 2 })
val state by model.state.collectAsStateWithLifecycle()
val pagerState = rememberPagerState(pageCount = { state.pages.size })
Scaffold(
/*
@ -111,7 +115,6 @@ fun NodeDetailsScreen(
Text(
text = "${stringResource(R.string.node_details)}: $nodeName",
)
HorizontalTabs(pagerState)
},
navigationIcon = {
IconButton(onClick = navigateBack) {
@ -124,53 +127,74 @@ fun NodeDetailsScreen(
)
},
) { innerPadding ->
HorizontalPager(state = pagerState) { page ->
when (page) {
0 -> DeviceMetricsScreen(
innerPadding = innerPadding,
telemetries = deviceMetrics
)
1 -> EnvironmentMetricsScreen(
innerPadding = innerPadding,
telemetries = environmentMetrics
NodeDetailsPagerScreen(
state = state,
pagerState = pagerState,
modifier = Modifier.padding(top = innerPadding.calculateTopPadding())
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NodeDetailsPagerScreen(
state: NodeDetailsState,
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]) {
NodeDetailPage.DEVICE -> DeviceMetricsScreen(deviceMetrics)
NodeDetailPage.ENVIRONMENT -> EnvironmentMetricsScreen(environmentMetrics)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@PreviewLightDark
@Composable
fun HorizontalTabs(pagerState: PagerState) {
Row(
Modifier
.wrapContentHeight()
.fillMaxWidth()
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.Center
) {
repeat(pagerState.pageCount) { iteration ->
val color = if (pagerState.currentPage == iteration)
colorResource(R.color.toolbarText)
else
Color.LightGray
val (imageVector, contentDescription) = if (iteration == 0)
Pair(ImageVector.vectorResource(
R.drawable.baseline_charging_station_24),
stringResource(R.string.device_metrics)
)
else
Pair(
ImageVector.vectorResource(R.drawable.baseline_thermostat_24),
stringResource(R.string.environment_metrics)
)
Icon(
imageVector,
contentDescription,
tint = color
)
}
private fun NodeDetailsPreview() {
AppTheme {
val state = NodeDetailsState.Empty
NodeDetailsPagerScreen(
state = state,
pagerState = rememberPagerState(pageCount = { state.pages.size }),
)
}
}

Wyświetl plik

@ -8,7 +8,6 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
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.fillMaxHeight
@ -77,7 +76,7 @@ private object ChartConstants {
}
@Composable
fun DeviceMetricsScreen(innerPadding: PaddingValues, telemetries: List<Telemetry>) {
fun DeviceMetricsScreen(telemetries: List<Telemetry>) {
Column {
DeviceMetricsChart(
modifier = Modifier
@ -87,9 +86,7 @@ fun DeviceMetricsScreen(innerPadding: PaddingValues, telemetries: List<Telemetry
)
/* Device Metric Cards */
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
modifier = Modifier.fillMaxSize()
) {
items(telemetries.reversed()) { telemetry -> DeviceMetricsCard(telemetry) }
}
@ -97,7 +94,7 @@ fun DeviceMetricsScreen(innerPadding: PaddingValues, telemetries: List<Telemetry
}
@Composable
fun EnvironmentMetricsScreen(innerPadding: PaddingValues, telemetries: List<Telemetry>) {
fun EnvironmentMetricsScreen(telemetries: List<Telemetry>) {
Column {
EnvironmentMetricsChart(
modifier = Modifier
@ -108,9 +105,7 @@ fun EnvironmentMetricsScreen(innerPadding: PaddingValues, telemetries: List<Tele
/* Environment Metric Cards */
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
modifier = Modifier.fillMaxSize()
) {
items(telemetries.reversed()) { telemetry -> EnvironmentMetricsCard(telemetry)}
}