From 96f1c936d95c151884c5bce9c7155a5a45616453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Mon, 14 Oct 2024 17:51:40 +0100 Subject: [PATCH 1/3] Choose websites --- .../composeResources/drawable/ic_add.xml | 9 + .../composeResources/drawable/ic_cancel.xml | 9 + .../values/strings-common.xml | 8 + .../kotlin/org/ooni/probe/di/Dependencies.kt | 12 + .../probe/domain/GetTestDescriptorsBySpec.kt | 14 +- .../ui/choosewebsites/ChooseWebsitesScreen.kt | 133 +++++++++++ .../choosewebsites/ChooseWebsitesViewModel.kt | 126 +++++++++++ .../probe/ui/descriptor/DescriptorScreen.kt | 152 ++++++++----- .../ui/descriptor/DescriptorViewModel.kt | 206 ++++++++++-------- .../ooni/probe/ui/navigation/Navigation.kt | 15 ++ .../org/ooni/probe/ui/navigation/Screen.kt | 2 + .../org/ooni/probe/ui/shared/UpdatesChip.kt | 7 +- .../org/ooni/probe/ui/shared/Validations.kt | 5 + .../ooni/probe/ui/shared/ValidationsTest.kt | 20 ++ 14 files changed, 563 insertions(+), 155 deletions(-) create mode 100644 composeApp/src/commonMain/composeResources/drawable/ic_add.xml create mode 100644 composeApp/src/commonMain/composeResources/drawable/ic_cancel.xml create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/Validations.kt create mode 100644 composeApp/src/commonTest/kotlin/org/ooni/probe/ui/shared/ValidationsTest.kt diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_add.xml b/composeApp/src/commonMain/composeResources/drawable/ic_add.xml new file mode 100644 index 000000000..19a0a9c8a --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/ic_cancel.xml b/composeApp/src/commonMain/composeResources/drawable/ic_cancel.xml new file mode 100644 index 000000000..f1ce55098 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/ic_cancel.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml index 37cfb43e3..acafd9af9 100644 --- a/composeApp/src/commonMain/composeResources/values/strings-common.xml +++ b/composeApp/src/commonMain/composeResources/values/strings-common.xml @@ -3,6 +3,7 @@ Last test: Estimated: N/A + Choose websites Running: Estimated time left: @@ -214,6 +215,13 @@ UPDATED UPDATE + Choose websites to test + URL + No URLs entered + Run + Add website + Test %1$d URLs + Back refresh diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt index f3bb442b6..e4f8b543c 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -74,6 +74,7 @@ import org.ooni.probe.domain.UploadMissingMeasurements import org.ooni.probe.shared.PlatformInfo import org.ooni.probe.shared.monitoring.AppLogger import org.ooni.probe.shared.monitoring.CrashMonitoring +import org.ooni.probe.ui.choosewebsites.ChooseWebsitesViewModel import org.ooni.probe.ui.dashboard.DashboardViewModel import org.ooni.probe.ui.descriptor.DescriptorViewModel import org.ooni.probe.ui.descriptor.add.AddDescriptorViewModel @@ -351,6 +352,15 @@ class Dependencies( fetchDescriptor = { fetchDescriptor(descriptorId) }, ) + fun chooseWebsitesViewModel( + onBack: () -> Unit, + goToDashboard: () -> Unit, + ) = ChooseWebsitesViewModel( + onBack = onBack, + goToDashboard = goToDashboard, + startBackgroundRun = startSingleRunInner, + ) + fun dashboardViewModel( goToOnboarding: () -> Unit, goToResults: () -> Unit, @@ -380,10 +390,12 @@ class Dependencies( descriptorKey: String, onBack: () -> Unit, goToReviewDescriptorUpdates: () -> Unit, + goToChooseWebsites: () -> Unit, ) = DescriptorViewModel( descriptorKey = descriptorKey, onBack = onBack, goToReviewDescriptorUpdates = goToReviewDescriptorUpdates, + goToChooseWebsites = goToChooseWebsites, getTestDescriptors = getTestDescriptors::invoke, getDescriptorLastResult = resultRepository::getLatestByDescriptor, preferenceRepository = preferenceRepository, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetTestDescriptorsBySpec.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetTestDescriptorsBySpec.kt index 02b505c22..4ec91cd5d 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetTestDescriptorsBySpec.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/GetTestDescriptorsBySpec.kt @@ -16,15 +16,11 @@ class GetTestDescriptorsBySpec( .mapNotNull { descriptor -> val specTest = spec.forDescriptor(descriptor) ?: return@mapNotNull null - val specDescriptor = - descriptor.copy( - netTests = - descriptor.netTests - .filter { specTest.netTests.contains(it) }, - longRunningTests = - descriptor.longRunningTests - .filter { specTest.netTests.contains(it) }, - ) + val specDescriptor = descriptor.copy( + netTests = specTest.netTests, + // long running are already inside netTests + longRunningTests = emptyList(), + ) if (specDescriptor.netTests.isEmpty() && specDescriptor.longRunningTests.isEmpty()) { return@mapNotNull null diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesScreen.kt new file mode 100644 index 000000000..33c2be6de --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesScreen.kt @@ -0,0 +1,133 @@ +package org.ooni.probe.ui.choosewebsites + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import ooniprobe.composeapp.generated.resources.CustomWebsites_Fab_Text +import ooniprobe.composeapp.generated.resources.Modal_Delete +import ooniprobe.composeapp.generated.resources.Res +import ooniprobe.composeapp.generated.resources.Settings_Websites_CustomURL_Add +import ooniprobe.composeapp.generated.resources.Settings_Websites_CustomURL_Title +import ooniprobe.composeapp.generated.resources.Settings_Websites_CustomURL_URL +import ooniprobe.composeapp.generated.resources.back +import ooniprobe.composeapp.generated.resources.ic_add +import ooniprobe.composeapp.generated.resources.ic_cancel +import ooniprobe.composeapp.generated.resources.ic_timer +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.ooni.probe.ui.shared.ColorDefaults + +@Composable +fun ChooseWebsitesScreen( + state: ChooseWebsitesViewModel.State, + onEvent: (ChooseWebsitesViewModel.Event) -> Unit, +) { + Column( + modifier = Modifier.padding(WindowInsets.navigationBars.asPaddingValues()), + ) { + TopAppBar( + title = { Text(stringResource(Res.string.Settings_Websites_CustomURL_Title)) }, + navigationIcon = { + IconButton(onClick = { onEvent(ChooseWebsitesViewModel.Event.BackClicked) }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back), + ) + } + }, + colors = ColorDefaults.topAppBar(), + ) + + Box(Modifier.fillMaxSize()) { + LazyColumn( + contentPadding = WindowInsets.navigationBars.asPaddingValues(), + ) { + itemsIndexed(state.websites) { index, item -> + OutlinedTextField( + value = item.url, + onValueChange = { + onEvent( + ChooseWebsitesViewModel.Event.UrlChanged( + index, + it, + ), + ) + }, + label = { stringResource(Res.string.Settings_Websites_CustomURL_URL) }, + isError = item.hasError, + trailingIcon = { + if (state.canRemoveUrls) { + IconButton( + onClick = { + onEvent(ChooseWebsitesViewModel.Event.DeleteWebsiteClicked(index)) + }, + ) { + Icon( + painterResource(Res.drawable.ic_cancel), + contentDescription = stringResource(Res.string.Modal_Delete), + ) + } + } + }, + modifier = Modifier.fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 16.dp), + ) + } + + item(key = "Add") { + TextButton( + onClick = { onEvent(ChooseWebsitesViewModel.Event.AddWebsiteClicked) }, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) { + Icon( + painterResource(Res.drawable.ic_add), + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) + Text(stringResource(Res.string.Settings_Websites_CustomURL_Add)) + } + } + } + + Button( + onClick = { onEvent(ChooseWebsitesViewModel.Event.RunClicked) }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(WindowInsets.navigationBars.asPaddingValues()) + .padding(bottom = 16.dp), + ) { + Icon( + painterResource(Res.drawable.ic_timer), + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) + Text( + stringResource(Res.string.CustomWebsites_Fab_Text, state.websites.size), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesViewModel.kt new file mode 100644 index 000000000..cda284d71 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesViewModel.kt @@ -0,0 +1,126 @@ +package org.ooni.probe.ui.choosewebsites + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import org.ooni.engine.models.TaskOrigin +import org.ooni.engine.models.TestType +import org.ooni.probe.data.models.NetTest +import org.ooni.probe.data.models.RunSpecification +import org.ooni.probe.ui.shared.isValidUrl + +class ChooseWebsitesViewModel( + onBack: () -> Unit, + goToDashboard: () -> Unit, + startBackgroundRun: (RunSpecification) -> Unit, +) : ViewModel() { + private val events = MutableSharedFlow(extraBufferCapacity = 1) + + private val _state = MutableStateFlow(State()) + val state = _state.asStateFlow() + + init { + events + .filterIsInstance() + .onEach { onBack() } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { _state.update { it.copy(websites = it.websites + WebsiteItem()) } } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { event -> + if (_state.value.websites.size <= 1) return@onEach + _state.update { + val newList = it.websites.filterIndexed { index, _ -> index != event.index } + it.copy(websites = newList) + } + } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { event -> + _state.update { + it.copy( + websites = it.websites.toMutableList() + .also { list -> + list[event.index] = list[event.index] + .copy(url = event.url, hasError = false) + } + .toList(), + ) + } + } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { + val items = _state.value.websites.map { item -> + item.copy(hasError = !item.url.isValidUrl()) + } + + if (items.any { it.hasError }) { + _state.value = _state.value.copy(websites = items) + return@onEach + } + + startBackgroundRun( + RunSpecification( + tests = listOf( + RunSpecification.Test( + source = RunSpecification.Test.Source.Default("websites"), + netTests = listOf( + NetTest( + test = TestType.WebConnectivity, + inputs = items.map { it.url }, + ), + ), + ), + ), + taskOrigin = TaskOrigin.OoniRun, + isRerun = false, + ), + ) + goToDashboard() + } + .launchIn(viewModelScope) + } + + fun onEvent(event: Event) { + events.tryEmit(event) + } + + data class WebsiteItem( + val url: String = "http://", + val hasError: Boolean = false, + ) + + data class State( + val websites: List = listOf(WebsiteItem()), + ) { + val canRemoveUrls get() = websites.size > 1 + } + + sealed interface Event { + data object BackClicked : Event + + data class UrlChanged(val index: Int, val url: String) : Event + + data class DeleteWebsiteClicked(val index: Int) : Event + + data object AddWebsiteClicked : Event + + data object RunClicked : Event + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorScreen.kt index 9b9a61363..3b191b362 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -30,11 +31,13 @@ import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import ooniprobe.composeapp.generated.resources.AddDescriptor_AutoRun import ooniprobe.composeapp.generated.resources.AddDescriptor_Settings +import ooniprobe.composeapp.generated.resources.Dashboard_Overview_ChooseWebsites import ooniprobe.composeapp.generated.resources.Dashboard_Overview_Estimated import ooniprobe.composeapp.generated.resources.Dashboard_Overview_LastRun_Never import ooniprobe.composeapp.generated.resources.Dashboard_Overview_LatestTest @@ -110,64 +113,7 @@ fun DescriptorScreen( .padding(WindowInsets.navigationBars.asPaddingValues()) .padding(bottom = 32.dp), ) { - Surface( - color = descriptorColor, - contentColor = onDescriptorColor, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() - .padding(8.dp), - ) { - descriptor.icon?.let { icon -> - Icon( - painterResource(icon), - contentDescription = null, - tint = onDescriptorColor, - modifier = Modifier.size(64.dp), - ) - } - - Row { - Text(stringResource(Res.string.Dashboard_Overview_Estimated)) - - descriptor.dataUsage()?.let { dataUsage -> - Text( - text = dataUsage, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 8.dp), - ) - } - - state.estimatedTime?.let { time -> - Text( - text = "~ ${time.shortFormat()}", - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 8.dp), - ) - } - } - - Row { - Text(stringResource(Res.string.Dashboard_Overview_LatestTest)) - - Text( - text = state.lastResult?.startTime?.relativeDateTime() - ?: stringResource(Res.string.Dashboard_Overview_LastRun_Never), - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(start = 8.dp), - ) - } - if (descriptor.updatable) { - UpdatesChip(onClick = { }) - } - state.updatedDescriptor?.let { - OutlinedButton(onClick = { onEvent(DescriptorViewModel.Event.UpdateDescriptor) }) { - Text(stringResource(Res.string.Dashboard_Runv2_Overview_ReviewUpdates)) - } - } - } - } + DescriptorDetails(state, onEvent) MarkdownViewer( markdown = descriptor.description().orEmpty(), @@ -230,6 +176,96 @@ fun DescriptorScreen( } } +@Composable +private fun DescriptorDetails( + state: DescriptorViewModel.State, + onEvent: (DescriptorViewModel.Event) -> Unit, +) { + val descriptor = state.descriptor ?: return + val descriptorColor = descriptor.color ?: MaterialTheme.colorScheme.primary + val onDescriptorColor = LocalCustomColors.current.onDescriptor + + Surface( + color = descriptorColor, + contentColor = onDescriptorColor, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + .padding(8.dp), + ) { + descriptor.icon?.let { icon -> + Icon( + painterResource(icon), + contentDescription = null, + tint = onDescriptorColor, + modifier = Modifier.size(64.dp), + ) + } + + Row { + Text(stringResource(Res.string.Dashboard_Overview_Estimated)) + + descriptor.dataUsage()?.let { dataUsage -> + Text( + text = dataUsage, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp), + ) + } + + state.estimatedTime?.let { time -> + Text( + text = "~ ${time.shortFormat()}", + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + + Row { + Text(stringResource(Res.string.Dashboard_Overview_LatestTest)) + + Text( + text = state.lastResult?.startTime?.relativeDateTime() + ?: stringResource(Res.string.Dashboard_Overview_LastRun_Never), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 8.dp), + ) + } + + if (descriptor.name == "websites") { + OutlinedButton( + onClick = { onEvent(DescriptorViewModel.Event.ChooseWebsitesClicked) }, + border = ButtonDefaults.outlinedButtonBorder.copy( + brush = SolidColor(onDescriptorColor), + ), + colors = ButtonDefaults.outlinedButtonColors(contentColor = onDescriptorColor), + modifier = Modifier.padding(top = 8.dp), + ) { + Text(stringResource(Res.string.Dashboard_Overview_ChooseWebsites)) + } + } + + if (descriptor.updatable) { + UpdatesChip(onClick = { }, modifier = Modifier.padding(top = 8.dp)) + } + state.updatedDescriptor?.let { + OutlinedButton( + onClick = { onEvent(DescriptorViewModel.Event.UpdateDescriptor) }, + border = ButtonDefaults.outlinedButtonBorder.copy( + brush = SolidColor(onDescriptorColor), + ), + colors = ButtonDefaults.outlinedButtonColors(contentColor = onDescriptorColor), + modifier = Modifier.padding(top = 8.dp), + ) { + Text(stringResource(Res.string.Dashboard_Runv2_Overview_ReviewUpdates)) + } + } + } + } +} + @Composable private fun TestItems( descriptor: Descriptor, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorViewModel.kt index e54a10f18..449f9a25c 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/descriptor/DescriptorViewModel.kt @@ -33,6 +33,7 @@ class DescriptorViewModel( private val descriptorKey: String, onBack: () -> Unit, goToReviewDescriptorUpdates: () -> Unit, + goToChooseWebsites: () -> Unit, private val getTestDescriptors: () -> Flow>, getDescriptorLastResult: (String) -> Flow, private val preferenceRepository: PreferenceRepository, @@ -50,29 +51,36 @@ class DescriptorViewModel( val state = _state.asStateFlow() init { - - descriptorUpdates().onEach { results -> - _state.update { - it.copy( - refreshType = if (results.refreshType != UpdateStatusType.ReviewLink) results.refreshType else UpdateStatusType.None, - ) - } - if (results.availableUpdates.size == 1) { - results.availableUpdates.first().let { updatedDescriptor -> - if (updatedDescriptor.id.value == descriptorKey.toLongOrNull()) { - _state.update { - it.copy( - updatedDescriptor = updatedDescriptor.toDescriptor(), - refreshType = UpdateStatusType.None, - ) + descriptorUpdates() + .onEach { results -> + _state.update { + it.copy( + refreshType = if (results.refreshType != UpdateStatusType.ReviewLink) { + results.refreshType + } else { + UpdateStatusType.None + }, + ) + } + if (results.availableUpdates.size == 1) { + results.availableUpdates.first().let { updatedDescriptor -> + if (updatedDescriptor.id.value == descriptorKey.toLongOrNull()) { + _state.update { + it.copy( + updatedDescriptor = updatedDescriptor.toDescriptor(), + refreshType = UpdateStatusType.None, + ) + } } } } } - }.launchIn(viewModelScope) - getDescriptor().onEach { if (it == null) onBack() }.filterNotNull() - .flatMapLatest { descriptor -> + .launchIn(viewModelScope) + getDescriptor() + .onEach { if (it == null) onBack() } + .filterNotNull() + .flatMapLatest { descriptor -> combine( preferenceRepository.areNetTestsEnabled( list = descriptor.allTests.map { descriptor to it }, @@ -95,81 +103,103 @@ class DescriptorViewModel( ) } } - }.launchIn(viewModelScope) + } + .launchIn(viewModelScope) + + getDescriptorLastResult(descriptorKey) + .onEach { lastResult -> + _state.update { + it.copy(lastResult = lastResult) + } + } + .launchIn(viewModelScope) + + events.filterIsInstance() + .onEach { onBack() } + .launchIn(viewModelScope) + + events.filterIsInstance() + .onEach { + val descriptor = state.value.descriptor ?: return@onEach + val allTestsSelected = state.value.tests.all { it.isSelected } + preferenceRepository.setAreNetTestsEnabled( + list = descriptor.allTests.map { descriptor to it }, + isAutoRun = true, + isEnabled = !allTestsSelected, + ) + } + .launchIn(viewModelScope) + + events.filterIsInstance() + .onEach { + val descriptor = state.value.descriptor ?: return@onEach + preferenceRepository.setAreNetTestsEnabled( + list = listOf(descriptor to it.test), + isAutoRun = true, + isEnabled = it.isChecked, + ) + } + .launchIn(viewModelScope) + + events.filterIsInstance() + .onEach { + deleteTestDescriptor(it.value) + } + .launchIn(viewModelScope) - getDescriptorLastResult(descriptorKey).onEach { lastResult -> - _state.update { - it.copy(lastResult = lastResult) + events.filterIsInstance() + .onEach { + launchUrl( + "${OrganizationConfig.ooniRunDashboardUrl}/revisions/$descriptorKey?revision=${it.revision}", + ) + } + .launchIn(viewModelScope) + + events.filterIsInstance() + .onEach { + launchUrl( + "${OrganizationConfig.ooniRunDashboardUrl}/revisions/$descriptorKey", + ) } - }.launchIn(viewModelScope) - - events.filterIsInstance().onEach { onBack() }.launchIn(viewModelScope) - - events.filterIsInstance().onEach { - val descriptor = state.value.descriptor ?: return@onEach - val allTestsSelected = state.value.tests.all { it.isSelected } - preferenceRepository.setAreNetTestsEnabled( - list = descriptor.allTests.map { descriptor to it }, - isAutoRun = true, - isEnabled = !allTestsSelected, - ) - }.launchIn(viewModelScope) - - events.filterIsInstance().onEach { - val descriptor = state.value.descriptor ?: return@onEach - preferenceRepository.setAreNetTestsEnabled( - list = listOf(descriptor to it.test), - isAutoRun = true, - isEnabled = it.isChecked, - ) - }.launchIn(viewModelScope) - - events.filterIsInstance().onEach { - deleteTestDescriptor(it.value) - }.launchIn(viewModelScope) - - events.filterIsInstance().onEach { - launchUrl( - "${OrganizationConfig.ooniRunDashboardUrl}/revisions/$descriptorKey?revision=${it.revision}", - ) - }.launchIn(viewModelScope) - - events.filterIsInstance().onEach { - - launchUrl( - "${OrganizationConfig.ooniRunDashboardUrl}/revisions/$descriptorKey", - ) - }.launchIn(viewModelScope) - - events.filterIsInstance().onEach { - - val descriptor = state.value.descriptor ?: return@onEach - if (descriptor.source !is Descriptor.Source.Installed) return@onEach - setAutoUpdate(descriptor.source.value.id, it.value) - }.launchIn(viewModelScope) - - events.filterIsInstance().onEach { - - if (state.value.isRefreshing) return@onEach - val descriptor = state.value.descriptor ?: return@onEach - - if (descriptor.source !is Descriptor.Source.Installed) return@onEach - _state.update { - it.copy(refreshType = UpdateStatusType.UpdateLink, updatedDescriptor = null) + .launchIn(viewModelScope) + + events.filterIsInstance() + .onEach { + val descriptor = state.value.descriptor ?: return@onEach + if (descriptor.source !is Descriptor.Source.Installed) return@onEach + setAutoUpdate(descriptor.source.value.id, it.value) } + .launchIn(viewModelScope) + + events.filterIsInstance() + .onEach { + if (state.value.isRefreshing) return@onEach + val descriptor = state.value.descriptor ?: return@onEach - fetchDescriptorUpdate(listOf(descriptor.source.value)) - }.launchIn(viewModelScope) + if (descriptor.source !is Descriptor.Source.Installed) return@onEach + _state.update { + it.copy(refreshType = UpdateStatusType.UpdateLink, updatedDescriptor = null) + } - events.filterIsInstance().onEach { - val descriptor = state.value.updatedDescriptor ?: return@onEach - if (descriptor.source !is Descriptor.Source.Installed) return@onEach - _state.update { - it.copy(refreshType = UpdateStatusType.None, updatedDescriptor = null) + fetchDescriptorUpdate(listOf(descriptor.source.value)) } - reviewUpdates(listOf(descriptor.source.value)) - goToReviewDescriptorUpdates() - }.launchIn(viewModelScope) + .launchIn(viewModelScope) + + events.filterIsInstance() + .onEach { + val descriptor = state.value.updatedDescriptor ?: return@onEach + if (descriptor.source !is Descriptor.Source.Installed) return@onEach + _state.update { + it.copy(refreshType = UpdateStatusType.None, updatedDescriptor = null) + } + reviewUpdates(listOf(descriptor.source.value)) + goToReviewDescriptorUpdates() + } + .launchIn(viewModelScope) + + events.filterIsInstance() + .onEach { goToChooseWebsites() } + .launchIn(viewModelScope) } fun onEvent(event: Event) { @@ -230,5 +260,7 @@ class DescriptorViewModel( data object FetchUpdatedDescriptor : Event data object UpdateDescriptor : Event + + data object ChooseWebsitesClicked : Event } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Navigation.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Navigation.kt index 73a7e5703..c662e85bb 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Navigation.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Navigation.kt @@ -18,6 +18,7 @@ import org.ooni.probe.data.models.PreferenceCategoryKey import org.ooni.probe.data.models.ResultModel import org.ooni.probe.di.Dependencies import org.ooni.probe.shared.decodeUrlFromBase64 +import org.ooni.probe.ui.choosewebsites.ChooseWebsitesScreen import org.ooni.probe.ui.dashboard.DashboardScreen import org.ooni.probe.ui.descriptor.DescriptorScreen import org.ooni.probe.ui.descriptor.add.AddDescriptorScreen @@ -268,6 +269,7 @@ fun Navigation( goToReviewDescriptorUpdates = { navController.navigate(Screen.ReviewUpdates.route) }, + goToChooseWebsites = { navController.navigate(Screen.ChooseWebsites.route) }, ) } val state by viewModel.state.collectAsState() @@ -283,5 +285,18 @@ fun Navigation( val state by viewModel.state.collectAsState() ReviewUpdatesScreen(state, viewModel::onEvent) } + + composable(route = Screen.ChooseWebsites.route) { entry -> + val viewModel = viewModel { + dependencies.chooseWebsitesViewModel( + onBack = { navController.popBackStack() }, + goToDashboard = { + navController.popBackStack(Screen.Dashboard.route, inclusive = false) + }, + ) + } + val state by viewModel.state.collectAsState() + ChooseWebsitesScreen(state, viewModel::onEvent) + } } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Screen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Screen.kt index ef0a8c806..af4800beb 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Screen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/Screen.kt @@ -91,4 +91,6 @@ sealed class Screen( } data object ReviewUpdates : Screen("review-updates") + + data object ChooseWebsites : Screen("choose-websites") } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/UpdatesChip.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/UpdatesChip.kt index efc6ffaa3..a8d597a90 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/UpdatesChip.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/UpdatesChip.kt @@ -5,12 +5,16 @@ import androidx.compose.material3.SuggestionChip import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import ooniprobe.composeapp.generated.resources.Dashboard_RunV2_UpdateTag import ooniprobe.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource @Composable -fun UpdatesChip(onClick: () -> Unit) { +fun UpdatesChip( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { SuggestionChip( onClick = onClick, enabled = false, @@ -18,5 +22,6 @@ fun UpdatesChip(onClick: () -> Unit) { labelColor = MaterialTheme.colorScheme.error, ), label = { Text(stringResource(Res.string.Dashboard_RunV2_UpdateTag)) }, + modifier = modifier, ) } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/Validations.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/Validations.kt new file mode 100644 index 000000000..15cc45111 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/Validations.kt @@ -0,0 +1,5 @@ +package org.ooni.probe.ui.shared + +fun String.isValidUrl() = + Regex("^https?://[\\w-.]+?\\..{2,}") + .matches(this) diff --git a/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/shared/ValidationsTest.kt b/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/shared/ValidationsTest.kt new file mode 100644 index 000000000..a9acde21e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/ooni/probe/ui/shared/ValidationsTest.kt @@ -0,0 +1,20 @@ +package org.ooni.probe.ui.shared + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ValidationsTest { + @Test + fun isValidUrl() { + assertFalse("".isValidUrl()) + assertFalse("http://".isValidUrl()) + assertFalse("https://".isValidUrl()) + assertFalse("http://a".isValidUrl()) + assertFalse("http://a.".isValidUrl()) + assertTrue("http://example.org".isValidUrl()) + assertTrue("http://example.co.uk".isValidUrl()) + assertTrue("http://example.org/path".isValidUrl()) + assertTrue("http://example.org?query=something".isValidUrl()) + } +} From 6625eda23e49a4870400f4022943676c18176a92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Tue, 15 Oct 2024 11:29:15 +0100 Subject: [PATCH 2/3] Confirm choose websites back --- .../values/strings-common.xml | 2 + .../ui/choosewebsites/ChooseWebsitesScreen.kt | 40 ++++++++++++++++++- .../choosewebsites/ChooseWebsitesViewModel.kt | 21 ++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings-common.xml b/composeApp/src/commonMain/composeResources/values/strings-common.xml index acafd9af9..91f3c117d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings-common.xml +++ b/composeApp/src/commonMain/composeResources/values/strings-common.xml @@ -221,6 +221,8 @@ Run Add website Test %1$d URLs + Are you sure? + Your URLs will not be saved when you leave this screen. Are you sure you want to leave this screen? Back diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesScreen.kt index 33c2be6de..dc29d5be7 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -25,7 +26,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import ooniprobe.composeapp.generated.resources.CustomWebsites_Fab_Text +import ooniprobe.composeapp.generated.resources.Modal_Cancel +import ooniprobe.composeapp.generated.resources.Modal_CustomURL_NotSaved +import ooniprobe.composeapp.generated.resources.Modal_CustomURL_Title_NotSaved import ooniprobe.composeapp.generated.resources.Modal_Delete +import ooniprobe.composeapp.generated.resources.Modal_OK import ooniprobe.composeapp.generated.resources.Res import ooniprobe.composeapp.generated.resources.Settings_Websites_CustomURL_Add import ooniprobe.composeapp.generated.resources.Settings_Websites_CustomURL_Title @@ -80,7 +85,11 @@ fun ChooseWebsitesScreen( if (state.canRemoveUrls) { IconButton( onClick = { - onEvent(ChooseWebsitesViewModel.Event.DeleteWebsiteClicked(index)) + onEvent( + ChooseWebsitesViewModel.Event.DeleteWebsiteClicked( + index, + ), + ) }, ) { Icon( @@ -130,4 +139,33 @@ fun ChooseWebsitesScreen( } } } + + if (state.showBackConfirmation) { + BackConfirmationDialog( + onConfirm = { onEvent(ChooseWebsitesViewModel.Event.BackConfirmed) }, + onDismiss = { onEvent(ChooseWebsitesViewModel.Event.BackCancelled) }, + ) + } +} + +@Composable +private fun BackConfirmationDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = { onDismiss() }, + title = { Text(stringResource(Res.string.Modal_CustomURL_Title_NotSaved)) }, + text = { Text(stringResource(Res.string.Modal_CustomURL_NotSaved)) }, + confirmButton = { + TextButton(onClick = { onConfirm() }) { + Text(stringResource(Res.string.Modal_OK)) + } + }, + dismissButton = { + TextButton(onClick = { onDismiss() }) { + Text(stringResource(Res.string.Modal_Cancel)) + } + }, + ) } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesViewModel.kt index cda284d71..81d1f03cb 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesViewModel.kt @@ -28,6 +28,22 @@ class ChooseWebsitesViewModel( init { events .filterIsInstance() + .onEach { + if (_state.value == State()) { + onBack() + } else { + _state.update { it.copy(showBackConfirmation = true) } + } + } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { _state.update { it.copy(showBackConfirmation = false) } } + .launchIn(viewModelScope) + + events + .filterIsInstance() .onEach { onBack() } .launchIn(viewModelScope) @@ -108,6 +124,7 @@ class ChooseWebsitesViewModel( data class State( val websites: List = listOf(WebsiteItem()), + val showBackConfirmation: Boolean = false, ) { val canRemoveUrls get() = websites.size > 1 } @@ -115,6 +132,10 @@ class ChooseWebsitesViewModel( sealed interface Event { data object BackClicked : Event + data object BackConfirmed : Event + + data object BackCancelled : Event + data class UrlChanged(val index: Int, val url: String) : Event data class DeleteWebsiteClicked(val index: Int) : Event From 9cb2c695c56d736b4c7e51bfdcc2443465ef8ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Santos?= Date: Tue, 15 Oct 2024 12:08:21 +0100 Subject: [PATCH 3/3] Avoid overlap between add website and run test buttons --- .../ooni/probe/ui/choosewebsites/ChooseWebsitesScreen.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesScreen.kt index dc29d5be7..91631c067 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/choosewebsites/ChooseWebsitesScreen.kt @@ -2,6 +2,7 @@ package org.ooni.probe.ui.choosewebsites import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -66,7 +67,10 @@ fun ChooseWebsitesScreen( Box(Modifier.fillMaxSize()) { LazyColumn( - contentPadding = WindowInsets.navigationBars.asPaddingValues(), + contentPadding = PaddingValues( + bottom = WindowInsets.navigationBars.asPaddingValues() + .calculateBottomPadding() + 64.dp, + ), ) { itemsIndexed(state.websites) { index, item -> OutlinedTextField( @@ -108,7 +112,7 @@ fun ChooseWebsitesScreen( item(key = "Add") { TextButton( onClick = { onEvent(ChooseWebsitesViewModel.Event.AddWebsiteClicked) }, - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier.padding(16.dp), ) { Icon( painterResource(Res.drawable.ic_add),