From b17bdd4fb8bdf603db8f8b3ce94429d4937d40ff Mon Sep 17 00:00:00 2001 From: andrekir Date: Sun, 1 Sep 2024 12:03:32 -0300 Subject: [PATCH] feat(NodeDetails): add loading state indicator and pager tabs --- .../mesh/model/NodeDetailsViewModel.kt | 48 ++++++- .../geeksville/mesh/ui/NodeDetailsFragment.kt | 130 +++++++++++------- .../mesh/ui/components/CustomCharts.kt | 13 +- 3 files changed, 125 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt index 17f0cb873..576fcc667 100644 --- a/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt @@ -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.entries, + val isLoading: Boolean = false, + val deviceMetrics: List = emptyList(), + val environmentMetrics: List = 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>(emptyList()) - val deviceMetrics: StateFlow> = _deviceMetrics - private val _environmentMetrics = MutableStateFlow>(emptyList()) - val environmentMetrics: StateFlow> = _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() val environmentList = mutableListOf() @@ -45,6 +84,7 @@ class NodeDetailsViewModel @Inject constructor( } _deviceMetrics.value = deviceList _environmentMetrics.value = environmentList + isLoading.value = false } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt index b4d9dddbe..5936f2e7e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt @@ -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 }), + ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt b/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt index 8d9d6edec..4946d3dd8 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt @@ -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) { +fun DeviceMetricsScreen(telemetries: List) { Column { DeviceMetricsChart( modifier = Modifier @@ -87,9 +86,7 @@ fun DeviceMetricsScreen(innerPadding: PaddingValues, telemetries: List DeviceMetricsCard(telemetry) } } @@ -97,7 +94,7 @@ fun DeviceMetricsScreen(innerPadding: PaddingValues, telemetries: List) { +fun EnvironmentMetricsScreen(telemetries: List) { Column { EnvironmentMetricsChart( modifier = Modifier @@ -108,9 +105,7 @@ fun EnvironmentMetricsScreen(innerPadding: PaddingValues, telemetries: List EnvironmentMetricsCard(telemetry)} }