diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_download.xml b/composeApp/src/commonMain/composeResources/drawable/ic_download.xml new file mode 100644 index 00000000..b917c5ae --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_download.xml @@ -0,0 +1,10 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_upload.xml b/composeApp/src/commonMain/composeResources/drawable/ic_upload.xml new file mode 100644 index 00000000..d155cb60 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_upload.xml @@ -0,0 +1,10 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml index 61f6e319..92476e68 100644 --- a/composeApp/src/commonMain/composeResources/values/strings-common.xml +++ b/composeApp/src/commonMain/composeResources/values/strings-common.xml @@ -41,9 +41,14 @@ Test Results Test Results Filter Tests + Tests + Networks + Data Usage No tests have been run yet. Try running one! Unknown N/A + Upload + Download Settings Notifications diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt index e380eaa5..d1cfc28c 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsScreen.kt @@ -4,10 +4,13 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog @@ -19,6 +22,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -42,14 +46,22 @@ import ooniprobe.composeapp.generated.resources.Res import ooniprobe.composeapp.generated.resources.Snackbar_ResultsSomeNotUploaded_Text import ooniprobe.composeapp.generated.resources.Snackbar_ResultsSomeNotUploaded_UploadAll import ooniprobe.composeapp.generated.resources.TestResults_Overview_FilterTests +import ooniprobe.composeapp.generated.resources.TestResults_Overview_Hero_DataUsage +import ooniprobe.composeapp.generated.resources.TestResults_Overview_Hero_Networks +import ooniprobe.composeapp.generated.resources.TestResults_Overview_Hero_Tests import ooniprobe.composeapp.generated.resources.TestResults_Overview_NoTestsHaveBeenRun import ooniprobe.composeapp.generated.resources.TestResults_Overview_Title +import ooniprobe.composeapp.generated.resources.TestResults_Summary_Performance_Hero_Download +import ooniprobe.composeapp.generated.resources.TestResults_Summary_Performance_Hero_Upload import ooniprobe.composeapp.generated.resources.ic_delete_all +import ooniprobe.composeapp.generated.resources.ic_download +import ooniprobe.composeapp.generated.resources.ic_upload import ooniprobe.composeapp.generated.resources.months import ooniprobe.composeapp.generated.resources.ooni_empty_state import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringArrayResource import org.jetbrains.compose.resources.stringResource +import org.ooni.probe.ui.shared.formatDataUsage @Composable fun ResultsScreen( @@ -111,6 +123,8 @@ fun ResultsScreen( } else if (state.results.isEmpty() && state.filter.isAll) { EmptyResults() } else { + Summary(state.summary) + LazyColumn { state.results.forEach { (date, results) -> stickyHeader(key = date.toString()) { @@ -194,6 +208,84 @@ private fun EmptyResults() { } } +@Composable +private fun Summary(summary: ResultsViewModel.Summary?) { + if (summary == null) return + + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) // So VerticalDividers don't expand to the whole screen + .padding(16.dp), + ) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + stringResource(Res.string.TestResults_Overview_Hero_Tests), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(bottom = 12.dp), + ) + Text( + summary.resultsCount.toString(), + style = MaterialTheme.typography.headlineMedium, + ) + } + + VerticalDivider(Modifier.padding(4.dp)) + + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + stringResource(Res.string.TestResults_Overview_Hero_Networks), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(bottom = 12.dp), + ) + Text( + summary.networksCount.toString(), + style = MaterialTheme.typography.headlineMedium, + ) + } + + VerticalDivider(Modifier.padding(4.dp)) + + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + stringResource(Res.string.TestResults_Overview_Hero_DataUsage), + style = MaterialTheme.typography.labelLarge, + ) + Row( + modifier = Modifier.padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painterResource(Res.drawable.ic_download), + contentDescription = stringResource(Res.string.TestResults_Summary_Performance_Hero_Download), + modifier = Modifier.size(20.dp), + ) + Text(summary.dataUsageDown.formatDataUsage()) + } + Row( + modifier = Modifier.padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painterResource(Res.drawable.ic_upload), + contentDescription = stringResource(Res.string.TestResults_Summary_Performance_Hero_Upload), + modifier = Modifier.size(20.dp), + ) + Text(summary.dataUsageUp.formatDataUsage()) + } + } + } +} + @Composable private fun ResultDateHeader(date: LocalDate) { val monthNames = stringArrayResource(Res.array.months) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsViewModel.kt index 5aad6591..58d69c47 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/results/ResultsViewModel.kt @@ -42,6 +42,7 @@ class ResultsViewModel( _state.update { it.copy( results = groupedResults, + summary = results.toSummary(), isLoading = false, ) } @@ -110,12 +111,28 @@ class ResultsViewModel( ResultFilter.Type.One(TaskOrigin.AutoRun), ), val results: Map> = emptyMap(), + val summary: Summary? = null, val isLoading: Boolean = true, ) { val anyMissingUpload get() = results.any { it.value.any { item -> !item.allMeasurementsUploaded } } } + data class Summary( + val resultsCount: Int, + val networksCount: Int, + val dataUsageUp: Long, + val dataUsageDown: Long, + ) + + private fun List.toSummary() = + Summary( + resultsCount = size, + networksCount = mapNotNull { it.network }.distinct().size, + dataUsageUp = sumOf { it.result.dataUsageUp }, + dataUsageDown = sumOf { it.result.dataUsageDown }, + ) + sealed interface Event { data class ResultClick(val result: ResultListItem) : Event diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/DataUsageFormats.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/DataUsageFormats.kt new file mode 100644 index 00000000..4a44fdb3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/DataUsageFormats.kt @@ -0,0 +1,18 @@ +package org.ooni.probe.ui.shared + +import kotlin.math.abs +import kotlin.math.log10 +import kotlin.math.pow + +fun Long.formatDataUsage(): String { + if (this <= 0) return "0" + val units = arrayOf("B", "kB", "MB", "GB", "TB", "PB", "EB") + val digitGroups = (log10(this.toDouble()) / log10(1024.0)).toInt() + return (this / 1024.0.pow(digitGroups.toDouble())).format() + " " + units[digitGroups] +} + +private fun Double.format(decimalChars: Int = 2): String { + val absoluteValue = abs(this).toInt() + val decimalValue = abs((this - absoluteValue) * 10.0.pow(decimalChars)).toInt() + return if (decimalValue == 0) absoluteValue.toString() else "$absoluteValue.$decimalValue" +}