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"
+}