Skip to content

Commit

Permalink
Merge pull request #349 from /issues/345
Browse files Browse the repository at this point in the history
feat: Group experimental measurements by type
  • Loading branch information
sdsantos authored Dec 18, 2024
2 parents c58e5c4 + 99dc389 commit f88eea6
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 74 deletions.
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package org.ooni.probe.ui.result

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
Expand All @@ -17,13 +19,17 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ooniprobe.composeapp.generated.resources.Common_Collapse
import ooniprobe.composeapp.generated.resources.Common_Expand
import ooniprobe.composeapp.generated.resources.Measurements_Anomaly
import ooniprobe.composeapp.generated.resources.Measurements_Failed
import ooniprobe.composeapp.generated.resources.Measurements_Ok
import ooniprobe.composeapp.generated.resources.Modal_UploadFailed_Title
import ooniprobe.composeapp.generated.resources.Res
import ooniprobe.composeapp.generated.resources.Snackbar_ResultsNotUploaded_Text
import ooniprobe.composeapp.generated.resources.ic_cloud_off
import ooniprobe.composeapp.generated.resources.ic_keyboard_arrow_down
import ooniprobe.composeapp.generated.resources.ic_keyboard_arrow_up
import ooniprobe.composeapp.generated.resources.ic_measurement_anomaly
import ooniprobe.composeapp.generated.resources.ic_measurement_failed
import ooniprobe.composeapp.generated.resources.ic_measurement_ok
Expand All @@ -32,6 +38,7 @@ import org.jetbrains.compose.resources.stringResource
import org.ooni.engine.models.TestType
import org.ooni.probe.data.models.MeasurementModel
import org.ooni.probe.data.models.MeasurementWithUrl
import org.ooni.probe.ui.result.ResultViewModel.MeasurementGroupItem.Group

@Composable
fun ResultMeasurementCell(
Expand All @@ -43,44 +50,15 @@ fun ResultMeasurementCell(
val test = measurement.test
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.run {
if (measurement.isDone && !measurement.isMissingUpload) {
clickable { onClick(measurement.reportId!!, item.url?.url) }
} else {
alpha(0.66f)
}
modifier = Modifier.fillMaxWidth().run {
if (measurement.isDone && !measurement.isMissingUpload) {
clickable { onClick(measurement.reportId!!, item.url?.url) }
} else {
alpha(0.66f)
}
.padding(16.dp),
}.padding(16.dp),
) {
val iconResource = when {
test == TestType.WebConnectivity && item.url != null -> item.url.category.icon
else -> test.iconRes
}

val contentDescription = item.url?.category?.title?.let { stringResource(it) } ?: ""
iconResource?.let { resource ->
Icon(
painterResource(resource),
contentDescription = contentDescription,
modifier = Modifier
.padding(end = 16.dp)
.size(24.dp),
)
}
Text(
text = if (test == TestType.WebConnectivity && item.url != null) {
item.url.url
} else if (test is TestType.Experimental) {
test.name
} else {
stringResource(test.labelRes)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(end = 8.dp),
)
TestName(test, item, modifier = Modifier.weight(1f))
if (isResultDone && measurement.isDoneAndMissingUpload) {
Icon(
painterResource(Res.drawable.ic_cloud_off),
Expand Down Expand Up @@ -117,16 +95,88 @@ fun ResultMeasurementCell(
},
),
tint = Color.Unspecified,
modifier = Modifier
.padding(end = 16.dp)
.size(24.dp),
modifier = Modifier.padding(end = 16.dp).size(24.dp),
)
} else if (!isResultDone) {
CircularProgressIndicator(
modifier = Modifier
.padding(end = 16.dp)
.size(24.dp),
modifier = Modifier.padding(end = 16.dp).size(24.dp),
)
}
}
}

@Composable
private fun TestName(
test: TestType,
item: MeasurementWithUrl,
modifier: Modifier = Modifier,
) {
val iconResource = when {
test == TestType.WebConnectivity && item.url != null -> item.url.category.icon
else -> test.iconRes
}

val contentDescription = item.url?.category?.title?.let { stringResource(it) } ?: ""
iconResource?.let { resource ->
Icon(
painterResource(resource),
contentDescription = contentDescription,
modifier = Modifier.padding(end = 16.dp).size(24.dp),
)
}
Text(
text = if (test == TestType.WebConnectivity && item.url != null) {
item.url.url
} else if (test is TestType.Experimental) {
test.name
} else {
stringResource(test.labelRes)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier.padding(end = 8.dp),
)
}

@Composable
fun ResultGroupMeasurementCell(
item: Group,
isResultDone: Boolean,
onClick: (MeasurementModel.ReportId, String?) -> Unit,
onDropdownToggled: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.clickable { onDropdownToggled() }
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
TestName(item.test, item.measurements.first(), modifier = Modifier.weight(1f))
IconButton(onClick = { onDropdownToggled() }) {
Icon(
painterResource(
if (item.isExpanded) {
Res.drawable.ic_keyboard_arrow_up
} else {
Res.drawable.ic_keyboard_arrow_down
},
),
contentDescription = stringResource(
if (item.isExpanded) {
Res.string.Common_Collapse
} else {
Res.string.Common_Expand
},
),
)
}
}

if (!item.isExpanded) return

Column(modifier = Modifier.padding(start = 32.dp)) {
item.measurements.forEach { measurement ->
ResultMeasurementCell(measurement, isResultDone, onClick)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.ooni.engine.models.NetworkType
import org.ooni.probe.data.models.ResultItem
import org.ooni.probe.ui.result.ResultViewModel.MeasurementGroupItem.Group
import org.ooni.probe.ui.result.ResultViewModel.MeasurementGroupItem.Single
import org.ooni.probe.ui.results.UploadResults
import org.ooni.probe.ui.shared.TopBar
import org.ooni.probe.ui.shared.formatDataUsage
Expand Down Expand Up @@ -143,14 +145,36 @@ fun ResultScreen(
}
}

items(state.result.measurements, key = { it.measurement.idOrThrow.value }) { item ->
ResultMeasurementCell(
item = item,
isResultDone = state.result.result.isDone,
onClick = { reportId, input ->
onEvent(ResultViewModel.Event.MeasurementClicked(reportId, input))
},
)
items(state.groupedMeasurements, key = { item ->
when (item) {
is Group -> item.test.name
is Single -> item.measurement.measurement.idOrThrow.value
}
}) { item ->
when (item) {
is Group -> {
ResultGroupMeasurementCell(
item = item,
isResultDone = state.result.result.isDone,
onClick = { reportId, input ->
onEvent(ResultViewModel.Event.MeasurementClicked(reportId, input))
},
onDropdownToggled = {
onEvent(ResultViewModel.Event.MeasurementGroupToggled(item))
},
)
}

is Single -> {
ResultMeasurementCell(
item = item.measurement,
isResultDone = state.result.result.isDone,
onClick = { reportId, input ->
onEvent(ResultViewModel.Event.MeasurementClicked(reportId, input))
},
)
}
}
}
}
}
Expand All @@ -171,8 +195,7 @@ private fun Summary(item: ResultItem) {
HorizontalPager(
state = pagerState,
verticalAlignment = Alignment.Top,
modifier = Modifier
.padding(top = 8.dp, bottom = 16.dp)
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp)
.defaultMinSize(minHeight = 128.dp),
) { page ->
when (page) {
Expand All @@ -181,30 +204,22 @@ private fun Summary(item: ResultItem) {
}
}
Row(
Modifier
.wrapContentHeight()
.fillMaxWidth()
.align(Alignment.BottomCenter)
Modifier.wrapContentHeight().fillMaxWidth().align(Alignment.BottomCenter)
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.Center,
) {
repeat(pagerState.pageCount) { index ->
Box(
modifier = Modifier
.padding(horizontal = 8.dp)
.padding(bottom = 8.dp)
.alpha(if (pagerState.currentPage == index) 1f else 0.33f)
.clip(CircleShape)
.background(LocalContentColor.current)
.size(12.dp),
modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 8.dp)
.alpha(if (pagerState.currentPage == index) 1f else 0.33f).clip(CircleShape)
.background(LocalContentColor.current).size(12.dp),
)
}
}
Icon(
painterResource(Res.drawable.ooni_bw),
contentDescription = null,
modifier = Modifier.align(Alignment.BottomEnd)
.offset(x = 18.dp, y = 18.dp),
modifier = Modifier.align(Alignment.BottomEnd).offset(x = 18.dp, y = 18.dp),
)
}
}
Expand Down Expand Up @@ -286,8 +301,7 @@ private fun SummaryNetwork(item: ResultItem) {
modifier = labelModifier,
)
Text(
item.network?.countryCode
?: stringResource(Res.string.TestResults_NotAvailable),
item.network?.countryCode ?: stringResource(Res.string.TestResults_NotAvailable),
modifier = valueModifier,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.update
import org.ooni.engine.models.TaskOrigin
import org.ooni.engine.models.TestType
import org.ooni.probe.data.models.MeasurementModel
import org.ooni.probe.data.models.MeasurementWithUrl
import org.ooni.probe.data.models.NetTest
import org.ooni.probe.data.models.ResultItem
import org.ooni.probe.data.models.ResultModel
Expand All @@ -36,14 +37,39 @@ class ResultViewModel(
startBackgroundRun: (RunSpecification) -> Unit,
) : ViewModel() {
private val events = MutableSharedFlow<Event>(extraBufferCapacity = 1)
private val expandedDescriptorsKeys = MutableStateFlow(emptyList<TestType>())

private val _state = MutableStateFlow(State(result = null))
private val _state = MutableStateFlow(State(result = null, groupedMeasurements = emptyList()))
val state = _state.asStateFlow()

init {
getResult(resultId)
.onEach { result ->
_state.update { it.copy(result = result) }
combine(
getResult(resultId),
expandedDescriptorsKeys,
::Pair,
)
.onEach { (result, expandedDescriptorsKeys) ->
var groupedMeasurements = listOf<MeasurementGroupItem>()
result?.measurements?.let { measurements ->
groupedMeasurements = measurements.groupBy { it.measurement.test }.flatMap { (key, itemList) ->
when {
itemList.size == 1 -> listOf(MeasurementGroupItem.Single(itemList.first()))
itemList.size > 1 && itemList.size == measurements.size -> itemList.map { MeasurementGroupItem.Single(it) }
else -> {
listOf(
MeasurementGroupItem.Group(
test = key,
measurements = itemList,
isExpanded = expandedDescriptorsKeys.contains(key),
),
)
}
}
}
}
_state.update {
it.copy(result = result, groupedMeasurements = groupedMeasurements)
}
if (result?.result?.isViewed == false) {
markResultAsViewed(resultId)
}
Expand Down Expand Up @@ -95,6 +121,20 @@ class ResultViewModel(
goToDashboard()
}
.launchIn(viewModelScope)

events
.filterIsInstance<Event.MeasurementGroupToggled>()
.onEach { event ->
expandedDescriptorsKeys.update { keys ->
val key = event.measurementGroup.test
if (keys.contains(key)) {
keys - key
} else {
keys + key
}
}
}
.launchIn(viewModelScope)
}

fun onEvent(event: Event) {
Expand Down Expand Up @@ -122,6 +162,7 @@ class ResultViewModel(

data class State(
val result: ResultItem?,
val groupedMeasurements: List<MeasurementGroupItem>,
val rerunEnabled: Boolean = false,
)

Expand All @@ -136,5 +177,17 @@ class ResultViewModel(
data object UploadClicked : Event

data object RerunClicked : Event

data class MeasurementGroupToggled(val measurementGroup: MeasurementGroupItem.Group) : Event
}

sealed class MeasurementGroupItem {
data class Single(val measurement: MeasurementWithUrl) : MeasurementGroupItem()

data class Group(
val test: TestType,
val measurements: List<MeasurementWithUrl>,
val isExpanded: Boolean,
) : MeasurementGroupItem()
}
}
Loading

0 comments on commit f88eea6

Please sign in to comment.