diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt index 8cf5d415..62bd9356 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/App.kt @@ -12,14 +12,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import co.touchlab.kermit.Logger import org.jetbrains.compose.ui.tooling.preview.Preview import org.ooni.probe.di.Dependencies import org.ooni.probe.ui.navigation.BottomNavigationBar import org.ooni.probe.ui.navigation.Navigation +import org.ooni.probe.ui.navigation.Screen import org.ooni.probe.ui.theme.AppTheme @Composable @@ -28,20 +31,37 @@ fun App(dependencies: Dependencies) { val navController = rememberNavController() val snackbarHostState = remember { SnackbarHostState() } + val currentNavEntry by navController.currentBackStackEntryAsState() + val currentRoute = currentNavEntry?.destination?.route + val isMainScreen = MAIN_NAVIGATION_SCREENS.map { it.route }.contains(currentRoute) + CompositionLocalProvider( values = arrayOf(LocalSnackbarHostState provides snackbarHostState), ) { - AppTheme { + AppTheme( + currentRoute = currentRoute, + ) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, ) { Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, - bottomBar = { BottomNavigationBar(navController) }, + bottomBar = { + if (isMainScreen) { + BottomNavigationBar(navController) + } + }, ) { paddingValues -> Box( - modifier = Modifier.padding(bottom = paddingValues.calculateBottomPadding()), + modifier = Modifier + .run { + if (isMainScreen) { + padding(bottom = paddingValues.calculateBottomPadding()) + } else { + this + } + }, ) { Navigation( navController = navController, @@ -73,3 +93,5 @@ private fun logAppStart(dependencies: Dependencies) { } val LocalSnackbarHostState = compositionLocalOf { null } + +val MAIN_NAVIGATION_SCREENS = listOf(Screen.Dashboard, Screen.Results, Screen.Settings) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestRunState.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestRunState.kt index 5c5992be..9335d72f 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestRunState.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/data/models/TestRunState.kt @@ -12,7 +12,7 @@ sealed interface TestRunState { ) : TestRunState data class Running( - val descriptorName: String? = null, + val descriptor: Descriptor? = null, val testType: TestType? = null, val estimatedRuntime: List? = null, val testProgress: Double = 0.0, 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 8d038011..f8f63278 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/di/Dependencies.kt @@ -42,11 +42,13 @@ import org.ooni.probe.domain.GetTestDescriptors import org.ooni.probe.domain.GetTestDescriptorsBySpec import org.ooni.probe.domain.RunDescriptors import org.ooni.probe.domain.RunNetTest +import org.ooni.probe.domain.SendSupportEmail import org.ooni.probe.domain.TestRunStateManager import org.ooni.probe.shared.PlatformInfo import org.ooni.probe.ui.dashboard.DashboardViewModel import org.ooni.probe.ui.result.ResultViewModel import org.ooni.probe.ui.results.ResultsViewModel +import org.ooni.probe.ui.running.RunningViewModel import org.ooni.probe.ui.settings.SettingsViewModel import org.ooni.probe.ui.settings.about.AboutViewModel import org.ooni.probe.ui.settings.category.SettingsCategoryViewModel @@ -176,27 +178,49 @@ class Dependencies( ) } + val sendSupportEmail by lazy { SendSupportEmail(platformInfo, launchUrl) } + private val testStateManager by lazy { TestRunStateManager(resultRepository.getLatest()) } // ViewModels - fun dashboardViewModel(goToResults: () -> Unit) = - DashboardViewModel( - goToResults = goToResults, - getTestDescriptors = getTestDescriptors::invoke, - runDescriptors = runDescriptors::invoke, - observeTestRunState = testStateManager.observeState(), - observeTestRunErrors = testStateManager.observeError(), + fun aboutViewModel(onBack: () -> Unit) = + AboutViewModel( + onBack = onBack, + launchUrl = { launchUrl(it, emptyMap()) }, ) + fun dashboardViewModel( + goToResults: () -> Unit, + goToRunningTest: () -> Unit, + ) = DashboardViewModel( + goToResults = goToResults, + goToRunningTest = goToRunningTest, + getTestDescriptors = getTestDescriptors::invoke, + runDescriptors = runDescriptors::invoke, + observeTestRunState = testStateManager.observeState(), + observeTestRunErrors = testStateManager.observeError(), + ) + fun resultsViewModel(goToResult: (ResultModel.Id) -> Unit) = ResultsViewModel(goToResult, getResults::invoke) + fun runningViewModel( + onBack: () -> Unit, + goToResults: () -> Unit, + ) = RunningViewModel( + onBack = onBack, + goToResults = goToResults, + observeTestRunState = testStateManager.observeState(), + observeTestRunErrors = testStateManager.observeError(), + cancelTestRun = testStateManager::cancelTestRun, + ) + fun settingsViewModel( goToSettingsForCategory: (PreferenceCategoryKey) -> Unit, - sendSupportEmail: () -> Unit, + sendSupportEmail: suspend () -> Unit, ) = SettingsViewModel( - goToSettingsForCategory, - sendSupportEmail, + goToSettingsForCategory = goToSettingsForCategory, + sendSupportEmail = sendSupportEmail, ) fun settingsCategoryViewModel( @@ -222,13 +246,6 @@ class Dependencies( markResultAsViewed = resultRepository::markAsViewed, ) - fun aboutViewModel(onBack: () -> Unit) = - AboutViewModel(onBack) { - launchUrl(it, emptyMap()) - } - - fun sendSupportEmail(): (String, Map) -> Unit = launchUrl - companion object { @VisibleForTesting fun buildJson() = diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunNetTest.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunNetTest.kt index 29a2b9f5..f75332b8 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunNetTest.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/RunNetTest.kt @@ -51,6 +51,7 @@ class RunNetTest( setCurrentTestState { if (it !is TestRunState.Running) return@setCurrentTestState it it.copy( + descriptor = spec.descriptor, testType = spec.netTest.test, testProgress = spec.testIndex * progressStep, testIndex = spec.testIndex, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/SendSupportEmail.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/SendSupportEmail.kt new file mode 100644 index 00000000..13315dce --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/domain/SendSupportEmail.kt @@ -0,0 +1,34 @@ +package org.ooni.probe.domain + +import ooniprobe.composeapp.generated.resources.Res +import ooniprobe.composeapp.generated.resources.Settings_SendEmail_Label +import ooniprobe.composeapp.generated.resources.Settings_SendEmail_Message +import ooniprobe.composeapp.generated.resources.shareEmailTo +import ooniprobe.composeapp.generated.resources.shareSubject +import org.jetbrains.compose.resources.getString +import org.ooni.probe.shared.PlatformInfo + +class SendSupportEmail( + private val platformInfo: PlatformInfo, + private val launchUrl: (String, Map?) -> Unit, +) { + suspend operator fun invoke() { + getString(Res.string.shareEmailTo) + val supportEmail = getString(Res.string.shareEmailTo) + val subject = getString(Res.string.shareSubject, platformInfo.version) + val chooserTitle = getString(Res.string.Settings_SendEmail_Label) + val body = getString(Res.string.Settings_SendEmail_Message) + "\n\n\n" + + "PLATFORM: ${platformInfo.platform}\n" + + "MODEL: ${platformInfo.model}\n" + + "OS Version: ${platformInfo.osVersion}" + + launchUrl( + supportEmail, + mapOf( + "subject" to subject, + "body" to body, + "chooserTitle" to chooserTitle, + ), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardScreen.kt index 196c130a..d8a27e86 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardScreen.kt @@ -19,10 +19,8 @@ import androidx.compose.material3.ElevatedButton import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -33,7 +31,6 @@ import ooniprobe.composeapp.generated.resources.Dashboard_Running_EstimatedTimeL import ooniprobe.composeapp.generated.resources.Dashboard_Running_Running import ooniprobe.composeapp.generated.resources.Dashboard_Running_Stopping_Notice import ooniprobe.composeapp.generated.resources.Dashboard_Running_Stopping_Title -import ooniprobe.composeapp.generated.resources.Modal_Error_CantDownloadURLs import ooniprobe.composeapp.generated.resources.OONIRun_Run import ooniprobe.composeapp.generated.resources.Res import ooniprobe.composeapp.generated.resources.app_name @@ -43,10 +40,9 @@ import ooniprobe.composeapp.generated.resources.ooni_empty_state import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview -import org.ooni.probe.LocalSnackbarHostState -import org.ooni.probe.data.models.TestRunError import org.ooni.probe.data.models.TestRunState import org.ooni.probe.ui.customColors +import org.ooni.probe.ui.shared.TestRunErrorMessages import org.ooni.probe.ui.shared.relativeDateTime import org.ooni.probe.ui.shared.shortFormat import org.ooni.probe.ui.theme.AppTheme @@ -87,7 +83,10 @@ fun DashboardScreen( } } - ErrorMessages(state, onEvent) + TestRunErrorMessages( + errors = state.testRunErrors, + onErrorDisplayed = { onEvent(DashboardViewModel.Event.ErrorDisplayed(it)) }, + ) } @Composable @@ -140,7 +139,7 @@ private fun TestRunStateSection( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .clickable { onEvent(DashboardViewModel.Event.RunningTestClick) } - .padding(horizontal = 16.dp, vertical = 4.dp), + .padding(horizontal = 16.dp, vertical = 8.dp), ) { state.testType?.let { testType -> Row { @@ -212,25 +211,6 @@ private fun TestRunStateSection( } } -@Composable -private fun ErrorMessages( - state: DashboardViewModel.State, - onEvent: (DashboardViewModel.Event) -> Unit, -) { - val snackbarHostState = LocalSnackbarHostState.current ?: return - val errorMessage = when (state.testRunErrors.firstOrNull()) { - TestRunError.DownloadUrlsFailed -> stringResource(Res.string.Modal_Error_CantDownloadURLs) - null -> "" - } - LaunchedEffect(state.testRunErrors) { - val error = state.testRunErrors.firstOrNull() ?: return@LaunchedEffect - val result = snackbarHostState.showSnackbar(errorMessage) - if (result == SnackbarResult.Dismissed) { - onEvent(DashboardViewModel.Event.ErrorDisplayed(error)) - } - } -} - @Preview @Composable fun DashboardScreenPreview() { diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardViewModel.kt index db19e25a..64fc9367 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/dashboard/DashboardViewModel.kt @@ -2,7 +2,9 @@ package org.ooni.probe.ui.dashboard import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -20,6 +22,7 @@ import org.ooni.probe.data.models.TestRunState class DashboardViewModel( goToResults: () -> Unit, + goToRunningTest: () -> Unit, getTestDescriptors: () -> Flow>, runDescriptors: suspend (RunSpecification) -> Unit, observeTestRunState: Flow, @@ -52,19 +55,16 @@ class DashboardViewModel( events .filterIsInstance() .onEach { - coroutineScope { - launch { - runDescriptors(buildRunSpecification()) - } + // TODO: This will become a StartTestRun domain class to start a background service + CoroutineScope(Dispatchers.IO).launch { + runDescriptors(buildRunSpecification()) } } .launchIn(viewModelScope) events .filterIsInstance() - .onEach { - // TODO - } + .onEach { goToRunningTest() } .launchIn(viewModelScope) events diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/measurement/MeasurementScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/measurement/MeasurementScreen.kt index 97cbcf19..e9a559b5 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/measurement/MeasurementScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/measurement/MeasurementScreen.kt @@ -1,9 +1,13 @@ package org.ooni.probe.ui.measurement 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.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Refresh @@ -80,7 +84,9 @@ fun MeasurementScreen( WebView( state = webViewState, navigator = webViewNavigator, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .padding(WindowInsets.navigationBars.asPaddingValues()), ) } } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt index 12a855c1..d787b66e 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/navigation/BottomNavigationBar.kt @@ -18,15 +18,13 @@ import ooniprobe.composeapp.generated.resources.ic_history import ooniprobe.composeapp.generated.resources.ic_settings import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +import org.ooni.probe.MAIN_NAVIGATION_SCREENS @Composable fun BottomNavigationBar(navController: NavController) { val entry by navController.currentBackStackEntryAsState() val currentRoute = entry?.destination?.route ?: return - // Only show the bottom app on the main screens - if (!MAIN_NAVIGATION_SCREENS.map { it.route }.contains(currentRoute)) return - NavigationBar { MAIN_NAVIGATION_SCREENS.forEach { screen -> NavigationBarItem( @@ -79,5 +77,3 @@ private val Screen.iconRes Screen.Settings -> Res.drawable.ic_settings else -> throw IllegalArgumentException("Only main screens allowed in bottom navigation") } - -private val MAIN_NAVIGATION_SCREENS = listOf(Screen.Dashboard, Screen.Results, Screen.Settings) 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 4652f6e7..acdd00c9 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 @@ -9,12 +9,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import ooniprobe.composeapp.generated.resources.Res -import ooniprobe.composeapp.generated.resources.Settings_SendEmail_Label -import ooniprobe.composeapp.generated.resources.Settings_SendEmail_Message -import ooniprobe.composeapp.generated.resources.shareEmailTo -import ooniprobe.composeapp.generated.resources.shareSubject -import org.jetbrains.compose.resources.stringResource import org.ooni.probe.data.models.MeasurementModel import org.ooni.probe.data.models.PreferenceCategoryKey import org.ooni.probe.data.models.ResultModel @@ -25,6 +19,7 @@ import org.ooni.probe.ui.dashboard.DashboardScreen import org.ooni.probe.ui.measurement.MeasurementScreen import org.ooni.probe.ui.result.ResultScreen import org.ooni.probe.ui.results.ResultsScreen +import org.ooni.probe.ui.running.RunningScreen import org.ooni.probe.ui.settings.SettingsScreen import org.ooni.probe.ui.settings.about.AboutScreen import org.ooni.probe.ui.settings.category.SettingsCategoryScreen @@ -43,6 +38,7 @@ fun Navigation( val viewModel = viewModel { dependencies.dashboardViewModel( goToResults = { navController.navigateToMainScreen(Screen.Results) }, + goToRunningTest = { navController.navigate(Screen.RunningTest.route) }, ) } val state by viewModel.state.collectAsState() @@ -50,40 +46,24 @@ fun Navigation( } composable(route = Screen.Results.route) { - val viewModel = - viewModel { - dependencies.resultsViewModel( - goToResult = { navController.navigate(Screen.Result(it).route) }, - ) - } + val viewModel = viewModel { + dependencies.resultsViewModel( + goToResult = { navController.navigate(Screen.Result(it).route) }, + ) + } val state by viewModel.state.collectAsState() ResultsScreen(state, viewModel::onEvent) } composable(route = Screen.Settings.route) { - val sendSupportEmail = dependencies.sendSupportEmail() - val supportEmail = stringResource(Res.string.shareEmailTo) - val subject = stringResource(Res.string.shareSubject, dependencies.platformInfo.version) - val chooserTitle = stringResource(Res.string.Settings_SendEmail_Label) - val platformInfo = dependencies.platformInfo - val body = stringResource(Res.string.Settings_SendEmail_Message) + "\n\n\n" + - "PLATFORM: ${platformInfo.platform}\n" + - "MODEL: ${platformInfo.model}\n" + - "OS Version: ${platformInfo.osVersion}" - val viewModel = - viewModel { - dependencies.settingsViewModel( - goToSettingsForCategory = { - navController.navigate(Screen.SettingsCategory(it).route) - }, - sendSupportEmail = { - sendSupportEmail.invoke( - supportEmail, - mapOf("subject" to subject, "body" to body, "chooserTitle" to chooserTitle), - ) - }, - ) - } + val viewModel = viewModel { + dependencies.settingsViewModel( + goToSettingsForCategory = { + navController.navigate(Screen.SettingsCategory(it).route) + }, + sendSupportEmail = dependencies.sendSupportEmail::invoke, + ) + } SettingsScreen(viewModel::onEvent) } @@ -92,16 +72,15 @@ fun Navigation( arguments = Screen.Result.ARGUMENTS, ) { entry -> val resultId = entry.arguments?.getLong("resultId") ?: return@composable - val viewModel = - viewModel { - dependencies.resultViewModel( - resultId = ResultModel.Id(resultId), - onBack = { navController.navigateUp() }, - goToMeasurement = { reportId, input -> - navController.navigate(Screen.Measurement(reportId, input).route) - }, - ) - } + val viewModel = viewModel { + dependencies.resultViewModel( + resultId = ResultModel.Id(resultId), + onBack = { navController.popBackStack() }, + goToMeasurement = { reportId, input -> + navController.navigate(Screen.Measurement(reportId, input).route) + }, + ) + } val state by viewModel.state.collectAsState() ResultScreen(state, viewModel::onEvent) } @@ -115,7 +94,7 @@ fun Navigation( MeasurementScreen( reportId = MeasurementModel.ReportId(reportId), input = input, - onBack = { navController.navigateUp() }, + onBack = { navController.popBackStack() }, ) } @@ -136,19 +115,17 @@ fun Navigation( } else -> { - val viewModel = - viewModel { - dependencies.settingsCategoryViewModel( - goToSettingsForCategory = { - navController.navigate(Screen.SettingsCategory(it).route) - }, - onBack = { navController.navigateUp() }, - category = - SettingsCategoryItem.getSettingsItem( - PreferenceCategoryKey.valueOf(category), - ), - ) - } + val viewModel = viewModel { + dependencies.settingsCategoryViewModel( + goToSettingsForCategory = { + navController.navigate(Screen.SettingsCategory(it).route) + }, + onBack = { navController.popBackStack() }, + category = SettingsCategoryItem.getSettingsItem( + PreferenceCategoryKey.valueOf(category), + ), + ) + } val state by viewModel.state.collectAsState() SettingsCategoryScreen( @@ -158,5 +135,19 @@ fun Navigation( } } } + + composable(route = Screen.RunningTest.route) { + val viewModel = viewModel { + dependencies.runningViewModel( + onBack = { navController.popBackStack() }, + goToResults = { + navController.popBackStack() + navController.navigateToMainScreen(Screen.Results) + }, + ) + } + val state by viewModel.state.collectAsState() + RunningScreen(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 8fc4a6d3..edeab434 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 @@ -50,4 +50,6 @@ sealed class Screen( val ARGUMENTS = listOf(navArgument("category") { type = NavType.StringType }) } } + + data object RunningTest : Screen("running") } diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/result/ResultScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/result/ResultScreen.kt index b63c1500..630674b6 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/result/ResultScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/result/ResultScreen.kt @@ -3,7 +3,10 @@ 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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -58,7 +61,9 @@ fun ResultScreen( if (state.result == null) return@Column - LazyColumn { + LazyColumn( + contentPadding = WindowInsets.navigationBars.asPaddingValues(), + ) { items(state.result.measurements, key = { it.measurement.idOrThrow.value }) { item -> ResultMeasurementItem( item = item, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/running/RunningScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/running/RunningScreen.kt new file mode 100644 index 00000000..b65c4f4a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/running/RunningScreen.kt @@ -0,0 +1,205 @@ +package org.ooni.probe.ui.running + +import androidx.compose.foundation.layout.Arrangement +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.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import ooniprobe.composeapp.generated.resources.Dashboard_Running_EstimatedTimeLeft +import ooniprobe.composeapp.generated.resources.Dashboard_Running_Running +import ooniprobe.composeapp.generated.resources.Dashboard_Running_Stopping_Notice +import ooniprobe.composeapp.generated.resources.Dashboard_Running_Stopping_Title +import ooniprobe.composeapp.generated.resources.Notification_StopTest +import ooniprobe.composeapp.generated.resources.Res +import ooniprobe.composeapp.generated.resources.back +import ooniprobe.composeapp.generated.resources.ooni_empty_state +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.ooni.probe.data.models.TestRunState +import org.ooni.probe.ui.customColors +import org.ooni.probe.ui.shared.TestRunErrorMessages +import org.ooni.probe.ui.shared.shortFormat + +@Composable +fun RunningScreen( + state: RunningViewModel.State, + onEvent: (RunningViewModel.Event) -> Unit, +) { + val descriptorColor = (state.testRunState as? TestRunState.Running)?.descriptor?.color + val contentColor = if (descriptorColor != null) { + MaterialTheme.customColors.onDescriptor + } else { + MaterialTheme.colorScheme.onSurface + } + Surface( + color = descriptorColor ?: MaterialTheme.colorScheme.surface, + contentColor = contentColor, + ) { + Column( + modifier = Modifier.padding(WindowInsets.navigationBars.asPaddingValues()), + ) { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = { onEvent(RunningViewModel.Event.BackClicked) }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back), + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + navigationIconContentColor = contentColor, + ), + ) + + when (state.testRunState) { + is TestRunState.Running -> TestRunning(state.testRunState, onEvent) + TestRunState.Stopping -> TestStopping() + else -> Unit + } + } + } + + TestRunErrorMessages( + errors = state.testRunErrors, + onErrorDisplayed = { onEvent(RunningViewModel.Event.ErrorDisplayed(it)) }, + ) +} + +@Composable +private fun TestRunning( + state: TestRunState.Running, + onEvent: (RunningViewModel.Event) -> Unit, +) { + val contentColor = LocalContentColor.current + + Column( + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + ) { + state.testType?.let { testType -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(Res.string.Dashboard_Running_Running), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(testType.labelRes), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + } + } + + Icon( + painterResource(state.descriptor?.icon ?: Res.drawable.ooni_empty_state), + contentDescription = null, + modifier = Modifier.size(96.dp), + ) + + val progressTrackColor = contentColor.copy(alpha = 0.5f) + val progressModifier = Modifier.fillMaxWidth() + .height(16.dp) + .clip(RoundedCornerShape(32.dp)) + + if (state.testProgress == 0.0) { + LinearProgressIndicator( + color = contentColor, + trackColor = progressTrackColor, + modifier = progressModifier, + ) + } else { + LinearProgressIndicator( + progress = { state.testProgress.toFloat() }, + color = contentColor, + trackColor = progressTrackColor, + modifier = progressModifier, + ) + } + + state.estimatedTimeLeft?.let { timeLeft -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(Res.string.Dashboard_Running_EstimatedTimeLeft), + ) + Text( + text = timeLeft.shortFormat(), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + } + } + + Text( + state.log.orEmpty(), + maxLines = 2, + textAlign = TextAlign.Center, + modifier = Modifier.height(56.dp), + ) + + OutlinedButton( + onClick = { onEvent(RunningViewModel.Event.StopTestClicked) }, + border = ButtonDefaults.outlinedButtonBorder.copy(brush = SolidColor(contentColor)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = contentColor), + ) { + Text(stringResource(Res.string.Notification_StopTest)) + } + } +} + +@Composable +private fun TestStopping() { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + ) { + Text( + text = stringResource(Res.string.Dashboard_Running_Stopping_Title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(Res.string.Dashboard_Running_Stopping_Notice), + textAlign = TextAlign.Center, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/running/RunningViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/running/RunningViewModel.kt new file mode 100644 index 00000000..fdbd44a5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/running/RunningViewModel.kt @@ -0,0 +1,84 @@ +package org.ooni.probe.ui.running + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.Flow +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.probe.data.models.TestRunError +import org.ooni.probe.data.models.TestRunState +import org.ooni.probe.ui.dashboard.DashboardViewModel.Event + +class RunningViewModel( + onBack: () -> Unit, + goToResults: () -> Unit, + observeTestRunState: Flow, + observeTestRunErrors: Flow, + cancelTestRun: () -> Unit, +) : ViewModel() { + private val events = MutableSharedFlow(extraBufferCapacity = 1) + + private val _state = MutableStateFlow(State()) + val state = _state.asStateFlow() + + init { + observeTestRunState + .onEach { testRunState -> + if (testRunState is TestRunState.Idle) { + if (testRunState.justFinishedTest) { + goToResults() + } else { + onBack() + } + return@onEach + } + _state.update { it.copy(testRunState = testRunState) } + } + .launchIn(viewModelScope) + + observeTestRunErrors + .onEach { error -> + _state.update { it.copy(testRunErrors = it.testRunErrors + error) } + } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { onBack() } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { cancelTestRun() } + .launchIn(viewModelScope) + + events + .filterIsInstance() + .onEach { event -> + _state.update { it.copy(testRunErrors = it.testRunErrors - event.error) } + } + .launchIn(viewModelScope) + } + + fun onEvent(event: Event) { + events.tryEmit(event) + } + + data class State( + val testRunState: TestRunState? = null, + val testRunErrors: List = emptyList(), + ) + + sealed interface Event { + data object BackClicked : Event + + data object StopTestClicked : Event + + data class ErrorDisplayed(val error: TestRunError) : Event + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/SettingsScreen.kt index 7ef971c4..be86130a 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/SettingsScreen.kt @@ -3,7 +3,11 @@ package org.ooni.probe.ui.settings import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.ListItem import androidx.compose.material3.Text @@ -21,7 +25,9 @@ import org.ooni.probe.data.models.SettingsCategoryItem @Composable fun SettingsScreen(onNavigateToSettingsCategory: (SettingsViewModel.Event) -> Unit) { - Column { + Column( + modifier = Modifier.padding(WindowInsets.navigationBars.asPaddingValues()), + ) { TopAppBar( title = { Text(stringResource(Res.string.Settings_Title)) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/SettingsViewModel.kt index fc21f130..b49156a8 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/SettingsViewModel.kt @@ -10,7 +10,7 @@ import org.ooni.probe.data.models.PreferenceCategoryKey open class SettingsViewModel( goToSettingsForCategory: (PreferenceCategoryKey) -> Unit, - sendSupportEmail: () -> Unit, + sendSupportEmail: suspend () -> Unit, ) : ViewModel() { private val events = MutableSharedFlow(extraBufferCapacity = 1) diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/category/SettingsCategoryScreen.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/category/SettingsCategoryScreen.kt index ba051157..147a3878 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/category/SettingsCategoryScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/settings/category/SettingsCategoryScreen.kt @@ -4,7 +4,10 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.clickable 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.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState @@ -54,7 +57,9 @@ fun SettingsCategoryScreen( }, ) Box( - modifier = Modifier.verticalScroll(rememberScrollState()).padding(bottom = 48.dp), + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(WindowInsets.navigationBars.asPaddingValues()), ) { Column { state.category.settings?.forEach { preferenceItem -> diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/TestRunErrorMessages.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/TestRunErrorMessages.kt new file mode 100644 index 00000000..d2585006 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/shared/TestRunErrorMessages.kt @@ -0,0 +1,29 @@ +package org.ooni.probe.ui.shared + +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import ooniprobe.composeapp.generated.resources.Modal_Error_CantDownloadURLs +import ooniprobe.composeapp.generated.resources.Res +import org.jetbrains.compose.resources.stringResource +import org.ooni.probe.LocalSnackbarHostState +import org.ooni.probe.data.models.TestRunError + +@Composable +fun TestRunErrorMessages( + errors: List, + onErrorDisplayed: (TestRunError) -> Unit, +) { + val snackbarHostState = LocalSnackbarHostState.current ?: return + val errorMessage = when (errors.firstOrNull()) { + TestRunError.DownloadUrlsFailed -> stringResource(Res.string.Modal_Error_CantDownloadURLs) + null -> "" + } + LaunchedEffect(errors) { + val error = errors.firstOrNull() ?: return@LaunchedEffect + val result = snackbarHostState.showSnackbar(errorMessage) + if (result == SnackbarResult.Dismissed) { + onErrorDisplayed(error) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/theme/AppTheme.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/theme/AppTheme.kt index 6ac634e6..1c28da9f 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/theme/AppTheme.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/theme/AppTheme.kt @@ -7,14 +7,16 @@ import androidx.compose.runtime.CompositionLocalProvider import org.ooni.probe.ui.LocalCustomColors import org.ooni.probe.ui.customColorsDark import org.ooni.probe.ui.customColorsLight +import org.ooni.probe.ui.navigation.Screen import org.ooni.probe.ui.shared.LightStatusBars @Composable fun AppTheme( useDarkTheme: Boolean = isSystemInDarkTheme(), + currentRoute: String? = null, content: @Composable () -> Unit, ) { - LightStatusBars(!useDarkTheme) + LightStatusBars(!useDarkTheme && currentRoute != Screen.RunningTest.route) CompositionLocalProvider( LocalCustomColors provides if (useDarkTheme) customColorsDark else customColorsLight, diff --git a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/theme/CustomColors.kt b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/theme/CustomColors.kt index e96f68b4..7a2515dc 100644 --- a/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/theme/CustomColors.kt +++ b/composeApp/src/commonMain/kotlin/org/ooni/probe/ui/theme/CustomColors.kt @@ -12,14 +12,17 @@ val successColorLight = Color(0xFF40c057) val onSuccessColorLight = Color.White val successColorDark = Color(0xFF2b8a3e) val onSuccessColorDark = Color.White +val onDescriptorColorLight = Color.White +val onDescriptorColorDark = Color.White data class CustomColors( val success: Color, val onSuccess: Color, + val onDescriptor: Color, ) -val customColorsLight = CustomColors(successColorLight, onSuccessColorLight) -val customColorsDark = CustomColors(successColorDark, onSuccessColorDark) +val customColorsLight = CustomColors(successColorLight, onSuccessColorLight, onDescriptorColorLight) +val customColorsDark = CustomColors(successColorDark, onSuccessColorDark, onDescriptorColorDark) internal val LocalCustomColors = staticCompositionLocalOf { customColorsLight }