diff --git a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenRobot.kt b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenRobot.kt index 683ca73..89cc619 100644 --- a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenRobot.kt +++ b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenRobot.kt @@ -8,12 +8,11 @@ import com.motionapps.sensorbox.domain.sensors.SensorDescriptor import com.motionapps.sensorbox.ui.theme.SensorBoxTheme class MeasurementSetupScreenRobot(private val rule: ComposeContentTestRule) { - fun givenMeasurementSetup(onIntent: (MainIntent) -> Unit = {}) = apply { + fun givenMeasurementSetup(onIntent: (RecordingIntent) -> Unit = {}) = apply { rule.setContent { SensorBoxTheme { MeasurementSetupScreen( - state = MainState( - route = MainRoute.SETUP, + state = RecordingState( sensors = listOf(SensorDescriptor(1, "Accelerometer", "Fixture", false)), selectedSensorIds = setOf(1), storagePath = "Fixture/SensorBox", diff --git a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenTest.kt b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenTest.kt index 73cfb4c..d020340 100644 --- a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenTest.kt +++ b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreenTest.kt @@ -11,14 +11,14 @@ class MeasurementSetupScreenTest { @Test fun givenConfiguredSetupWhenStartIsTappedThenMeasurementIntentIsSent() { - var actualIntent: MainIntent? = null + var actualIntent: RecordingIntent? = null MeasurementSetupScreenRobot(composeRule) .givenMeasurementSetup { actualIntent = it } .thenFolderAndSettingsAreVisible() .whenStartMeasurementIsTapped() composeRule.runOnIdle { - assertEquals(MainIntent.StartMeasurement, actualIntent) + assertEquals(RecordingIntent.StartMeasurement, actualIntent) } } } diff --git a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/OnboardingScreenRobot.kt b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/OnboardingScreenRobot.kt index bb51b6b..1154cba 100644 --- a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/OnboardingScreenRobot.kt +++ b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/OnboardingScreenRobot.kt @@ -14,20 +14,22 @@ import androidx.compose.ui.test.performClick import com.motionapps.sensorbox.ui.theme.SensorBoxTheme class OnboardingScreenRobot(private val rule: ComposeContentTestRule) { - var lastIntent: MainIntent? = null + var lastIntent: OnboardingIntent? = null private set fun givenInteractiveOnboarding(page: Int = 0, storagePath: String? = null) = apply { rule.setContent { var state by remember { - mutableStateOf( - MainState(route = MainRoute.ONBOARDING, onboardingPage = page, storagePath = storagePath), - ) + mutableStateOf(OnboardingState(page = page, storagePath = storagePath)) } SensorBoxTheme { OnboardingScreen(state) { intent -> lastIntent = intent - state = MainReducer.reduce(state, intent).state + state = when (intent) { + OnboardingIntent.AdvanceOnboarding -> state.copy(page = state.page + 1) + OnboardingIntent.RetreatOnboarding -> state.copy(page = (state.page - 1).coerceAtLeast(0)) + else -> state + } } } } diff --git a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/OnboardingScreenTest.kt b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/OnboardingScreenTest.kt index e17878d..89a074a 100644 --- a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/OnboardingScreenTest.kt +++ b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/OnboardingScreenTest.kt @@ -16,15 +16,15 @@ class OnboardingScreenTest { robot.thenPageIsVisible("Welcome to SensorBox").whenNextIsTapped() robot.thenPageIsVisible("Nothing is going out").whenNextIsTapped() robot.thenPageIsVisible("Privacy and terms").whenPrivacyPolicyIsTapped() - assertEquals(MainIntent.OpenPrivacyPolicy, robot.lastIntent) + assertEquals(OnboardingIntent.OpenPrivacyPolicy, robot.lastIntent) robot.whenTermsOfUseIsTapped() - assertEquals(MainIntent.OpenTermsOfUse, robot.lastIntent) + assertEquals(OnboardingIntent.OpenTermsOfUse, robot.lastIntent) robot.whenNextIsTapped().thenPageIsVisible("Android may pause recordings").whenNextIsTapped() robot.thenPageIsVisible("Allow reliable background work").whenBatterySettingsIsTapped() - assertEquals(MainIntent.RequestBatteryOptimizationExemption, robot.lastIntent) + assertEquals(OnboardingIntent.RequestBatteryOptimizationExemption, robot.lastIntent) robot.whenNextIsTapped().thenPageIsVisible("Choose a recording folder") robot.thenFinishIsDisabled().whenChooseFolderIsTapped() - assertEquals(MainIntent.ChooseStorage, robot.lastIntent) + assertEquals(OnboardingIntent.ChooseStorage, robot.lastIntent) } @Test @@ -36,6 +36,6 @@ class OnboardingScreenTest { robot.thenFinishIsEnabled().whenFinishIsTapped() - assertEquals(MainIntent.CompleteOnboarding, robot.lastIntent) + assertEquals(OnboardingIntent.CompleteOnboarding, robot.lastIntent) } } diff --git a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenRobot.kt b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenRobot.kt index dd93231..342f7ed 100644 --- a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenRobot.kt +++ b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenRobot.kt @@ -12,13 +12,12 @@ class RecordScreenRobot(private val rule: ComposeContentTestRule) { fun givenRecordScreen( selected: Boolean = false, gpsSelected: Boolean = false, - onIntent: (MainIntent) -> Unit = {}, + onIntent: (RecordingIntent) -> Unit = {}, ) = apply { rule.setContent { SensorBoxTheme { RecordScreen( - state = MainState( - route = MainRoute.RECORD, + state = RecordingState( sensors = listOf(SensorDescriptor(1, "Accelerometer", "Fixture", false)), selectedSensorIds = if (selected) setOf(1) else emptySet(), includesGps = gpsSelected, diff --git a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenTest.kt b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenTest.kt index 36755f2..f9bc39c 100644 --- a/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenTest.kt +++ b/app/src/androidTest/java/com/motionapps/sensorbox/presentation/main/RecordScreenTest.kt @@ -11,62 +11,62 @@ class RecordScreenTest { @Test fun givenRecordScreenWhenSensorIsTappedThenToggleIntentIsSent() { - var actualIntent: MainIntent? = null + var actualIntent: RecordingIntent? = null RecordScreenRobot(composeRule) .givenRecordScreen { actualIntent = it } .thenRecordingActionIsVisible() .whenAccelerometerIsTapped() composeRule.runOnIdle { - assertEquals(MainIntent.ToggleSensor(1), actualIntent) + assertEquals(RecordingIntent.ToggleSensor(1), actualIntent) } } @Test fun givenRecordScreenWhenSensorInfoIsTappedThenDetailsIntentIsSent() { - var actualIntent: MainIntent? = null + var actualIntent: RecordingIntent? = null RecordScreenRobot(composeRule) .givenRecordScreen { actualIntent = it } .whenAccelerometerInfoIsTapped() composeRule.runOnIdle { - assertEquals(MainIntent.OpenSensorDetails(1), actualIntent) + assertEquals(RecordingIntent.OpenSensorDetails(1), actualIntent) } } @Test fun givenSelectedSensorWhenContinueIsTappedThenSetupIntentIsSent() { - var actualIntent: MainIntent? = null + var actualIntent: RecordingIntent? = null RecordScreenRobot(composeRule) .givenRecordScreen(selected = true) { actualIntent = it } .whenContinueIsTapped() composeRule.runOnIdle { - assertEquals(MainIntent.OpenMeasurementSetup, actualIntent) + assertEquals(RecordingIntent.OpenMeasurementSetup, actualIntent) } } @Test fun givenRecordScreenWhenGpsIsTappedThenToggleGpsIntentIsSent() { - var actualIntent: MainIntent? = null + var actualIntent: RecordingIntent? = null RecordScreenRobot(composeRule) .givenRecordScreen { actualIntent = it } .whenGpsIsTapped() composeRule.runOnIdle { - assertEquals(MainIntent.ToggleGps, actualIntent) + assertEquals(RecordingIntent.ToggleGps, actualIntent) } } @Test fun givenOnlyGpsSelectedWhenContinueIsTappedThenSetupIntentIsSent() { - var actualIntent: MainIntent? = null + var actualIntent: RecordingIntent? = null RecordScreenRobot(composeRule) .givenRecordScreen(gpsSelected = true) { actualIntent = it } .whenContinueIsTapped() composeRule.runOnIdle { - assertEquals(MainIntent.OpenMeasurementSetup, actualIntent) + assertEquals(RecordingIntent.OpenMeasurementSetup, actualIntent) } } } diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/AboutScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/AboutScreen.kt index 72b3458..92c1d27 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/AboutScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/AboutScreen.kt @@ -33,7 +33,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.motionapps.sensorbox.BuildConfig import com.motionapps.sensorbox.R -import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode import com.motionapps.sensorbox.core.error.appResult @Composable @@ -83,7 +83,7 @@ private fun AboutDialogBody(onPrivacy: () -> Unit) { fun OpenSourceLicensesScreen(onBack: () -> Unit, modifier: Modifier = Modifier) { val resources = LocalContext.current.resources val licenses = remember(resources) { - appResult(AppError.Kind.STORAGE, "Load open source licenses") { + appResult(AppErrorCode.STORAGE, "Load open source licenses") { loadOpenSourceLicenses(resources) }.getOrDefault(emptyList()) } diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/ActiveMeasurementScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/ActiveMeasurementScreen.kt index 5b4518b..dee1240 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/ActiveMeasurementScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/ActiveMeasurementScreen.kt @@ -53,7 +53,7 @@ import com.motionapps.sensorbox.ui.theme.SensorBoxRecording import com.motionapps.sensorservices.session.MeasurementSessionState @Composable -fun ActiveMeasurementScreen(state: MainState, onIntent: (MainIntent) -> Unit, modifier: Modifier = Modifier) { +fun ActiveMeasurementScreen(state: RecordingState, onIntent: (RecordingIntent) -> Unit, modifier: Modifier = Modifier) { val session = state.session as? MeasurementSessionState.Running ?: return Column( modifier = modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 24.dp), @@ -74,14 +74,14 @@ fun ActiveMeasurementScreen(state: MainState, onIntent: (MainIntent) -> Unit, mo } SensorBoxDangerButton( label = stringResource(R.string.stop_and_save), - onClick = { onIntent(MainIntent.StopMeasurement) }, + onClick = { onIntent(RecordingIntent.StopMeasurement) }, modifier = Modifier.fillMaxWidth(), ) } } @Composable -private fun AnnotationEditor(onIntent: (MainIntent) -> Unit) { +private fun AnnotationEditor(onIntent: (RecordingIntent) -> Unit) { var annotation by remember { mutableStateOf("") } SensorBoxPanel { Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { @@ -97,7 +97,7 @@ private fun AnnotationEditor(onIntent: (MainIntent) -> Unit) { label = stringResource(R.string.add_annotation), onClick = { annotation.trim().takeIf(String::isNotEmpty)?.let { - onIntent(MainIntent.AddAnnotation(it)) + onIntent(RecordingIntent.AddAnnotation(it)) annotation = "" } }, @@ -168,7 +168,7 @@ private fun MeasurementTimer(elapsedSeconds: Long, folderName: String) { } @Composable -private fun MeasurementSummary(state: MainState, session: MeasurementSessionState.Running) { +private fun MeasurementSummary(state: RecordingState, session: MeasurementSessionState.Running) { var expanded by remember { mutableStateOf(false) } val sources = recordingSourceNames(state, session, LocalContext.current.resources) SensorBoxPanel { @@ -230,7 +230,7 @@ private fun RecordingSourceList(sources: List) { } private fun recordingSourceNames( - state: MainState, + state: RecordingState, session: MeasurementSessionState.Running, resources: Resources, ): List = buildList { diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreen.kt index 5c0375d..eaa6cd3 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MeasurementSetupScreen.kt @@ -29,10 +29,10 @@ import com.motionapps.sensorbox.R @Composable fun MeasurementSetupScreen( - state: MainState, - onIntent: (MainIntent) -> Unit, + state: RecordingState, + onIntent: (RecordingIntent) -> Unit, modifier: Modifier = Modifier, - onBack: () -> Unit = { onIntent(MainIntent.ReturnToSensorSelection) }, + onBack: () -> Unit = { onIntent(RecordingIntent.ReturnToSensorSelection) }, ) { Box(modifier.fillMaxSize()) { MeasurementSetupContent(state, onIntent, onBack) @@ -41,17 +41,21 @@ fun MeasurementSetupScreen( } @Composable -private fun MeasurementSetupContent(state: MainState, onIntent: (MainIntent) -> Unit, onBack: () -> Unit) { +private fun MeasurementSetupContent(state: RecordingState, onIntent: (RecordingIntent) -> Unit, onBack: () -> Unit) { LazyColumn( contentPadding = PaddingValues(start = 20.dp, top = 12.dp, end = 20.dp, bottom = 132.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { item { SensorBoxTopAppBar(stringResource(R.string.measurement_setup), onBack) } - item { StorageSetupPanel(state.storagePath) { onIntent(MainIntent.ChooseStorage) } } + item { StorageSetupPanel(state.storagePath) { onIntent(RecordingIntent.ChooseStorage) } } item { MeasurementNameSetup(state, onIntent) } item { TimingSetup(state, onIntent) } item { NotesAndAlarmsSetup(state, onIntent) } - item { SamplingSetting(state.preferences.sensorSamplingPeriod, onIntent) } + item { + SamplingSetting(state.preferences.sensorSamplingPeriod) { index -> + onIntent(RecordingIntent.SetSamplingPeriod(index)) + } + } item { SpecializedSourcesSetup(state, onIntent) } item { BatterySetup(state, onIntent) } item { WakeLockSetup(state, onIntent) } @@ -60,16 +64,16 @@ private fun MeasurementSetupContent(state: MainState, onIntent: (MainIntent) -> item { GpsIntervalSetup(state, onIntent) } item { GpsDistanceSetup(state, onIntent) } } - item { MainMessageText(state.message) } + item { RecordingMessageText(state.message) } } } @Composable -private fun MeasurementNameSetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun MeasurementNameSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { SensorBoxPanel { OutlinedTextField( value = state.customMeasurementName, - onValueChange = { onIntent(MainIntent.SetCustomMeasurementName(it)) }, + onValueChange = { onIntent(RecordingIntent.SetCustomMeasurementName(it)) }, modifier = Modifier.fillMaxWidth().padding(16.dp), label = { Text(stringResource(R.string.custom_measurement_name)) }, supportingText = { Text(stringResource(R.string.custom_measurement_name_description)) }, @@ -79,20 +83,20 @@ private fun MeasurementNameSetup(state: MainState, onIntent: (MainIntent) -> Uni } @Composable -private fun TimingSetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun TimingSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { BooleanSetting( title = stringResource(R.string.timed_measurement), description = stringResource(R.string.timed_measurement_description), checked = state.measurementType == "TIMED", - ) { onIntent(MainIntent.SetMeasurementType(if (it) "TIMED" else "ENDLESS")) } + ) { onIntent(RecordingIntent.SetMeasurementType(if (it) "TIMED" else "ENDLESS")) } StepSetting( stringResource(R.string.start_delay), state.startDelaySeconds, pluralStringResource(R.plurals.seconds_count, state.startDelaySeconds, state.startDelaySeconds), 0, 86_400, - ) { onIntent(MainIntent.SetStartDelay(it)) } + ) { onIntent(RecordingIntent.SetStartDelay(it)) } if (state.measurementType == "TIMED") { StepSetting( stringResource(R.string.measurement_duration), @@ -104,18 +108,18 @@ private fun TimingSetup(state: MainState, onIntent: (MainIntent) -> Unit) { ), 1, 86_400, - ) { onIntent(MainIntent.SetDuration(it)) } + ) { onIntent(RecordingIntent.SetDuration(it)) } } } } @Composable -private fun NotesAndAlarmsSetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun NotesAndAlarmsSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { SensorBoxPanel { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedTextField( value = state.notes, - onValueChange = { onIntent(MainIntent.SetNotes(it)) }, + onValueChange = { onIntent(RecordingIntent.SetNotes(it)) }, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.measurement_notes)) }, supportingText = { Text(stringResource(R.string.measurement_notes_description)) }, @@ -123,7 +127,7 @@ private fun NotesAndAlarmsSetup(state: MainState, onIntent: (MainIntent) -> Unit ) OutlinedTextField( value = state.alarmOffsets, - onValueChange = { onIntent(MainIntent.SetAlarmOffsets(it)) }, + onValueChange = { onIntent(RecordingIntent.SetAlarmOffsets(it)) }, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.audible_alarm_offsets)) }, supportingText = { Text(stringResource(R.string.audible_alarm_offsets_description)) }, @@ -134,13 +138,13 @@ private fun NotesAndAlarmsSetup(state: MainState, onIntent: (MainIntent) -> Unit } @Composable -private fun SpecializedSourcesSetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun SpecializedSourcesSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { BooleanSetting( title = stringResource(R.string.activity_recognition), description = stringResource(R.string.activity_recognition_description), checked = state.activityRecognition, - ) { onIntent(MainIntent.SetActivityRecognition(it)) } + ) { onIntent(RecordingIntent.SetActivityRecognition(it)) } if (state.activityRecognition) { StepSetting( stringResource(R.string.activity_recognition_period), @@ -152,13 +156,13 @@ private fun SpecializedSourcesSetup(state: MainState, onIntent: (MainIntent) -> ), 1, 3_600, - ) { onIntent(MainIntent.SetActivityRecognitionPeriod(it)) } + ) { onIntent(RecordingIntent.SetActivityRecognitionPeriod(it)) } } BooleanSetting( title = stringResource(R.string.significant_motion), description = stringResource(R.string.significant_motion_description), checked = state.significantMotion, - ) { onIntent(MainIntent.SetSignificantMotion(it)) } + ) { onIntent(RecordingIntent.SetSignificantMotion(it)) } } } @@ -191,34 +195,34 @@ private fun StorageSetupPanel(path: String?, onChoose: () -> Unit) { } @Composable -private fun BatterySetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun BatterySetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.battery_guard), description = stringResource(R.string.battery_guard_setup_description), checked = state.preferences.restrictMeasurementOnLowBattery, - ) { onIntent(MainIntent.SetLowBatteryRestriction(it)) } + ) { onIntent(RecordingIntent.SetLowBatteryRestriction(it)) } } @Composable -private fun WakeLockSetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun WakeLockSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.keep_cpu_awake), description = stringResource(R.string.keep_cpu_awake_setup_description), checked = state.preferences.useWakeLock, - ) { onIntent(MainIntent.SetWakeLock(it)) } + ) { onIntent(RecordingIntent.SetWakeLock(it)) } } @Composable -private fun KeepScreenAwakeSetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun KeepScreenAwakeSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.keep_screen_awake), description = stringResource(R.string.keep_screen_awake_setup_description), checked = state.preferences.keepPhoneDisplayOn, - ) { onIntent(MainIntent.SetKeepScreenAwake(it)) } + ) { onIntent(RecordingIntent.SetKeepScreenAwake(it)) } } @Composable -private fun GpsIntervalSetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun GpsIntervalSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { StepSetting( stringResource(R.string.gps_interval), state.preferences.gpsIntervalSeconds, @@ -230,12 +234,12 @@ private fun GpsIntervalSetup(state: MainState, onIntent: (MainIntent) -> Unit) { 1, 3_600, ) { - onIntent(MainIntent.SetGpsInterval(it)) + onIntent(RecordingIntent.SetGpsInterval(it)) } } @Composable -private fun GpsDistanceSetup(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun GpsDistanceSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { StepSetting( stringResource(R.string.gps_minimum_distance), state.preferences.gpsMinDistanceMeters, @@ -247,12 +251,16 @@ private fun GpsDistanceSetup(state: MainState, onIntent: (MainIntent) -> Unit) { 0, 10_000, ) { - onIntent(MainIntent.SetGpsDistance(it)) + onIntent(RecordingIntent.SetGpsDistance(it)) } } @Composable -private fun MeasurementSetupActionBar(state: MainState, onIntent: (MainIntent) -> Unit, modifier: Modifier = Modifier) { +private fun MeasurementSetupActionBar( + state: RecordingState, + onIntent: (RecordingIntent) -> Unit, + modifier: Modifier = Modifier, +) { val sourceCount = setupSourceCount(state) SensorBoxBottomAction( title = pluralStringResource(R.plurals.source_count, sourceCount, sourceCount), @@ -261,11 +269,12 @@ private fun MeasurementSetupActionBar(state: MainState, onIntent: (MainIntent) - ), buttonLabel = stringResource(R.string.start_measurement), enabled = state.storagePath != null && sourceCount > 0, - onClick = { onIntent(MainIntent.StartMeasurement) }, + onClick = { onIntent(RecordingIntent.StartMeasurement) }, modifier = modifier, ) } -private fun setupSourceCount(state: MainState): Int = state.selectedSensorIds.size + state.selectedWearSensorIds.size + - (if (state.includesGps) 1 else 0) + (if (state.wearIncludesGps) 1 else 0) + - (if (state.activityRecognition) 1 else 0) + (if (state.significantMotion) 1 else 0) +private fun setupSourceCount(state: RecordingState): Int = + state.selectedSensorIds.size + state.selectedWearSensorIds.size + + (if (state.includesGps) 1 else 0) + (if (state.wearIncludesGps) 1 else 0) + + (if (state.activityRecognition) 1 else 0) + (if (state.significantMotion) 1 else 0) diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingScreen.kt index 12b6292..1901324 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingScreen.kt @@ -35,8 +35,8 @@ import androidx.compose.ui.unit.dp import com.motionapps.sensorbox.R @Composable -fun OnboardingScreen(state: MainState, onIntent: (MainIntent) -> Unit) { - val pageIndex = state.onboardingPage.coerceIn(ONBOARDING_PAGES.indices) +fun OnboardingScreen(state: OnboardingState, onIntent: (OnboardingIntent) -> Unit) { + val pageIndex = state.page.coerceIn(ONBOARDING_PAGES.indices) val page = ONBOARDING_PAGES[pageIndex] BoxWithConstraints( modifier = Modifier @@ -53,9 +53,9 @@ fun OnboardingScreen(state: MainState, onIntent: (MainIntent) -> Unit) { private fun OnboardingLayout( pageIndex: Int, page: OnboardingPage, - state: MainState, + state: OnboardingState, isLandscape: Boolean, - onIntent: (MainIntent) -> Unit, + onIntent: (OnboardingIntent) -> Unit, ) { Column( modifier = Modifier @@ -79,13 +79,13 @@ private fun OnboardingLayout( } @Composable -private fun OnboardingHeader(pageIndex: Int, onIntent: (MainIntent) -> Unit) { +private fun OnboardingHeader(pageIndex: Int, onIntent: (OnboardingIntent) -> Unit) { Column(Modifier.fillMaxWidth()) { Box(Modifier.fillMaxWidth().height(52.dp)) { if (pageIndex > 0) { SensorBoxBackButton( label = stringResource(R.string.intro_back), - onClick = { onIntent(MainIntent.RetreatOnboarding) }, + onClick = { onIntent(OnboardingIntent.RetreatOnboarding) }, modifier = Modifier.align(Alignment.CenterStart), ) } @@ -118,9 +118,9 @@ private fun OnboardingProgress(pageIndex: Int) { @Composable private fun OnboardingMessage( page: OnboardingPage, - state: MainState, + state: OnboardingState, isLandscape: Boolean, - onIntent: (MainIntent) -> Unit, + onIntent: (OnboardingIntent) -> Unit, ) { if (isLandscape) { LandscapeOnboardingMessage(page, state, onIntent) @@ -130,7 +130,11 @@ private fun OnboardingMessage( } @Composable -private fun PortraitOnboardingMessage(page: OnboardingPage, state: MainState, onIntent: (MainIntent) -> Unit) { +private fun PortraitOnboardingMessage( + page: OnboardingPage, + state: OnboardingState, + onIntent: (OnboardingIntent) -> Unit, +) { Column( modifier = Modifier .widthIn(max = ONBOARDING_MESSAGE_MAX_WIDTH) @@ -148,7 +152,11 @@ private fun PortraitOnboardingMessage(page: OnboardingPage, state: MainState, on } @Composable -private fun LandscapeOnboardingMessage(page: OnboardingPage, state: MainState, onIntent: (MainIntent) -> Unit) { +private fun LandscapeOnboardingMessage( + page: OnboardingPage, + state: OnboardingState, + onIntent: (OnboardingIntent) -> Unit, +) { Row( modifier = Modifier .fillMaxSize() @@ -208,7 +216,7 @@ private fun OnboardingText(page: OnboardingPage) { } @Composable -private fun OnboardingPageActions(action: OnboardingAction, path: String?, onIntent: (MainIntent) -> Unit) { +private fun OnboardingPageActions(action: OnboardingAction, path: String?, onIntent: (OnboardingIntent) -> Unit) { when (action) { OnboardingAction.NONE -> Unit OnboardingAction.POLICIES -> PolicyActions(onIntent) @@ -218,38 +226,38 @@ private fun OnboardingPageActions(action: OnboardingAction, path: String?, onInt } @Composable -private fun PolicyActions(onIntent: (MainIntent) -> Unit) { +private fun PolicyActions(onIntent: (OnboardingIntent) -> Unit) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { SensorBoxSecondaryButton( label = stringResource(R.string.intro_policy_button), - onClick = { onIntent(MainIntent.OpenPrivacyPolicy) }, + onClick = { onIntent(OnboardingIntent.OpenPrivacyPolicy) }, modifier = Modifier.weight(1f), ) SensorBoxSecondaryButton( label = stringResource(R.string.intro_terms_button), - onClick = { onIntent(MainIntent.OpenTermsOfUse) }, + onClick = { onIntent(OnboardingIntent.OpenTermsOfUse) }, modifier = Modifier.weight(1f), ) } } @Composable -private fun BatteryAction(onIntent: (MainIntent) -> Unit) { +private fun BatteryAction(onIntent: (OnboardingIntent) -> Unit) { SensorBoxSecondaryButton( label = stringResource(R.string.intro_battery_action), - onClick = { onIntent(MainIntent.RequestBatteryOptimizationExemption) }, + onClick = { onIntent(OnboardingIntent.RequestBatteryOptimizationExemption) }, modifier = Modifier.fillMaxWidth(), ) } @Composable -private fun StorageAction(path: String?, onIntent: (MainIntent) -> Unit) { +private fun StorageAction(path: String?, onIntent: (OnboardingIntent) -> Unit) { Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { SensorBoxSecondaryButton( label = stringResource( if (path == null) R.string.intro_storage_action else R.string.intro_storage_change_action, ), - onClick = { onIntent(MainIntent.ChooseStorage) }, + onClick = { onIntent(OnboardingIntent.ChooseStorage) }, modifier = Modifier.fillMaxWidth(), ) if (path != null) { @@ -269,7 +277,7 @@ private fun OnboardingControls( pageIndex: Int, hasStorage: Boolean, isLandscape: Boolean, - onIntent: (MainIntent) -> Unit, + onIntent: (OnboardingIntent) -> Unit, modifier: Modifier = Modifier, ) { val isLastPage = pageIndex == ONBOARDING_PAGES.lastIndex @@ -285,8 +293,8 @@ private fun OnboardingControls( } } -private fun onboardingForwardIntent(isLastPage: Boolean): MainIntent = - if (isLastPage) MainIntent.CompleteOnboarding else MainIntent.AdvanceOnboarding +private fun onboardingForwardIntent(isLastPage: Boolean): OnboardingIntent = + if (isLastPage) OnboardingIntent.CompleteOnboarding else OnboardingIntent.AdvanceOnboarding private data class OnboardingPage( @StringRes val title: Int, diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordScreen.kt index 56fe893..0f4bc74 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordScreen.kt @@ -36,7 +36,7 @@ import com.motionapps.sensorbox.R import com.motionapps.sensorbox.domain.sensors.SensorDescriptor @Composable -fun RecordScreen(state: MainState, onIntent: (MainIntent) -> Unit, modifier: Modifier = Modifier) { +fun RecordScreen(state: RecordingState, onIntent: (RecordingIntent) -> Unit, modifier: Modifier = Modifier) { Box(modifier.fillMaxSize()) { RecordContent(state, onIntent) SensorSelectionActionBar(state, onIntent, Modifier.align(Alignment.BottomCenter)) @@ -44,27 +44,27 @@ fun RecordScreen(state: MainState, onIntent: (MainIntent) -> Unit, modifier: Mod } @Composable -private fun RecordContent(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun RecordContent(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { LazyColumn( contentPadding = PaddingValues(start = 20.dp, top = 24.dp, end = 20.dp, bottom = 132.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { - item { RecordHeader(state) { onIntent(MainIntent.Navigate(MainRoute.SETTINGS)) } } + item { RecordHeader(state) { onIntent(RecordingIntent.Navigate(MainRoute.SETTINGS)) } } item { DeviceSectionHeader(stringResource(R.string.phone_sensors)) } item { SensorSectionHeader(phoneSourceCount(state), state.sensors.size + 1) } item { GpsRow( selected = state.includesGps, - onToggle = { onIntent(MainIntent.ToggleGps) }, - onInfo = { onIntent(MainIntent.OpenSensorDetails(null)) }, + onToggle = { onIntent(RecordingIntent.ToggleGps) }, + onInfo = { onIntent(RecordingIntent.OpenSensorDetails(null)) }, ) } items(state.sensors, key = SensorDescriptor::type) { sensor -> SensorRow( sensor = sensor, selected = sensor.type in state.selectedSensorIds, - onToggle = { onIntent(MainIntent.ToggleSensor(sensor.type)) }, - onInfo = { onIntent(MainIntent.OpenSensorDetails(sensor.type)) }, + onToggle = { onIntent(RecordingIntent.ToggleSensor(sensor.type)) }, + onInfo = { onIntent(RecordingIntent.OpenSensorDetails(sensor.type)) }, ) } if (state.isWearConnected) { @@ -73,20 +73,20 @@ private fun RecordContent(state: MainState, onIntent: (MainIntent) -> Unit) { item { GpsRow( selected = state.wearIncludesGps, - onToggle = { onIntent(MainIntent.ToggleWearGps) }, - onInfo = { onIntent(MainIntent.OpenSensorDetails(null)) }, + onToggle = { onIntent(RecordingIntent.ToggleWearGps) }, + onInfo = { onIntent(RecordingIntent.OpenSensorDetails(null)) }, ) } items(state.wearSensors, key = { "wear_${it.type}" }) { sensor -> SensorRow( sensor = sensor, selected = sensor.type in state.selectedWearSensorIds, - onToggle = { onIntent(MainIntent.ToggleWearSensor(sensor.type)) }, - onInfo = { onIntent(MainIntent.OpenSensorDetails(sensor.type)) }, + onToggle = { onIntent(RecordingIntent.ToggleWearSensor(sensor.type)) }, + onInfo = { onIntent(RecordingIntent.OpenSensorDetails(sensor.type)) }, ) } } - item { MainMessageText(state.message) } + item { RecordingMessageText(state.message) } } } @@ -123,7 +123,7 @@ private fun GpsRow(selected: Boolean, onToggle: () -> Unit, onInfo: () -> Unit) } @Composable -private fun RecordHeader(state: MainState, onOptions: () -> Unit) { +private fun RecordHeader(state: RecordingState, onOptions: () -> Unit) { val optionsDescription = stringResource(R.string.options) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { SensorBoxScreenHeader( @@ -210,29 +210,34 @@ private fun sensorBorderColor(selected: Boolean) = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant @Composable -private fun SensorSelectionActionBar(state: MainState, onIntent: (MainIntent) -> Unit, modifier: Modifier = Modifier) { +private fun SensorSelectionActionBar( + state: RecordingState, + onIntent: (RecordingIntent) -> Unit, + modifier: Modifier = Modifier, +) { val sensorCount = selectedSourceCount(state) SensorBoxBottomAction( title = pluralStringResource(R.plurals.sensor_count, sensorCount, sensorCount), description = stringResource(R.string.step_one_of_two), buttonLabel = stringResource(R.string.continue_action), enabled = sensorCount > 0, - onClick = { onIntent(MainIntent.OpenMeasurementSetup) }, + onClick = { onIntent(RecordingIntent.OpenMeasurementSetup) }, modifier = modifier, ) } -private fun phoneSourceCount(state: MainState): Int = state.selectedSensorIds.size + if (state.includesGps) 1 else 0 +private fun phoneSourceCount(state: RecordingState): Int = + state.selectedSensorIds.size + if (state.includesGps) 1 else 0 -private fun wearSourceCount(state: MainState): Int = +private fun wearSourceCount(state: RecordingState): Int = state.selectedWearSensorIds.size + if (state.wearIncludesGps) 1 else 0 -private fun selectedSourceCount(state: MainState): Int = phoneSourceCount(state) + wearSourceCount(state) + +private fun selectedSourceCount(state: RecordingState): Int = phoneSourceCount(state) + wearSourceCount(state) + (if (state.activityRecognition) 1 else 0) + (if (state.significantMotion) 1 else 0) @Composable -fun MainMessageText(message: MainMessage) { - if (message == MainMessage.NONE) return +fun RecordingMessageText(message: RecordingMessage) { + if (message == RecordingMessage.NONE) return Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium) { Text( stringResource(messageTextResource(message)), @@ -243,10 +248,10 @@ fun MainMessageText(message: MainMessage) { } @StringRes -private fun messageTextResource(message: MainMessage): Int = when (message) { - MainMessage.PICK_AT_LEAST_ONE_SOURCE -> R.string.message_pick_source - MainMessage.STORAGE_REQUIRED -> R.string.message_storage_required - MainMessage.PERMISSION_REQUIRED -> R.string.message_permission_required - MainMessage.MEASUREMENT_FAILED -> R.string.message_measurement_failed - MainMessage.NONE -> R.string.app_name +private fun messageTextResource(message: RecordingMessage): Int = when (message) { + RecordingMessage.PICK_AT_LEAST_ONE_SOURCE -> R.string.message_pick_source + RecordingMessage.STORAGE_REQUIRED -> R.string.message_storage_required + RecordingMessage.PERMISSION_REQUIRED -> R.string.message_permission_required + RecordingMessage.MEASUREMENT_FAILED -> R.string.message_measurement_failed + RecordingMessage.NONE -> R.string.app_name } diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorDetailsScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorDetailsScreen.kt index 1eeb41c..dcf9535 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorDetailsScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorDetailsScreen.kt @@ -36,7 +36,12 @@ import com.motionapps.sensorbox.domain.sensors.SensorDescriptor import com.motionapps.sensorservices.handlers.GPSHandler @Composable -fun SensorDetailsScreen(state: MainState, onBack: () -> Unit, onPreview: () -> Unit, modifier: Modifier = Modifier) { +fun SensorDetailsScreen( + state: RecordingState, + onBack: () -> Unit, + onPreview: () -> Unit, + modifier: Modifier = Modifier, +) { val sensor = state.detailsSensorType?.let { type -> state.sensors.firstOrNull { it.type == type } } val canPreview = state.detailsSensorType == null || sensor?.type != Sensor.TYPE_STEP_DETECTOR val title = when { @@ -105,7 +110,7 @@ private fun HardwareSensorDetails(sensor: SensorDescriptor) { } @Composable -private fun GpsDetails(state: MainState) { +private fun GpsDetails(state: RecordingState) { var permissionRevision by remember { mutableStateOf(0) } val permissionRequest = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), @@ -134,7 +139,7 @@ private fun GpsDetails(state: MainState) { } @Composable -private fun GpsDetailRows(details: GpsDetailsState, state: MainState, unavailableValue: String) { +private fun GpsDetailRows(details: GpsDetailsState, state: RecordingState, unavailableValue: String) { DetailRow( stringResource(R.string.detail_latitude), details.location?.latitude?.toString() ?: unavailableValue, diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorPreviewScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorPreviewScreen.kt index b4682e8..177c4f9 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorPreviewScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorPreviewScreen.kt @@ -50,7 +50,7 @@ import com.motionapps.sensorbox.domain.sensors.SensorDescriptor import kotlin.math.max @Composable -fun SensorPreviewScreen(state: MainState, onBack: () -> Unit, modifier: Modifier = Modifier) { +fun SensorPreviewScreen(state: RecordingState, onBack: () -> Unit, modifier: Modifier = Modifier) { val sensor = state.detailsSensorType?.let { type -> state.sensors.firstOrNull { it.type == type } } val title = when { state.detailsSensorType == null -> stringResource(R.string.gps_preview) @@ -310,7 +310,7 @@ private fun ChartLegend(label: String, color: Color) { } @Composable -private fun GpsPreview(state: MainState) { +private fun GpsPreview(state: RecordingState) { var permissionRevision by remember { mutableIntStateOf(0) } val permissionRequest = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsScreen.kt index be3723c..5656660 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsScreen.kt @@ -10,10 +10,14 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults @@ -45,10 +49,10 @@ import com.motionapps.sensorbox.R @Composable fun SettingsScreen( - state: MainState, - onIntent: (MainIntent) -> Unit, + state: SettingsState, + onIntent: (SettingsIntent) -> Unit, modifier: Modifier = Modifier, - onBack: () -> Unit = { onIntent(MainIntent.Navigate(MainRoute.RECORD)) }, + onBack: () -> Unit = { onIntent(SettingsIntent.Navigate(MainRoute.RECORD)) }, ) { val isBatteryOptimizationExempt = rememberBatteryOptimizationExemption() LazyColumn( @@ -59,19 +63,24 @@ fun SettingsScreen( item { SensorBoxTopAppBar(stringResource(R.string.measurement_settings), onBack) } - item { SamplingSetting(state.preferences.sensorSamplingPeriod, onIntent) } + item { + SamplingSetting(state.preferences.sensorSamplingPeriod) { index -> + onIntent(SettingsIntent.SetSamplingPeriod(index)) + } + } item { BatteryGuardSetting(state, onIntent) } item { BatteryOptimizationSetting(isBatteryOptimizationExempt, onIntent) } item { CpuWakeLockSetting(state, onIntent) } item { ScreenAwakeSetting(state, onIntent) } item { GpsSettings(state, onIntent) } - item { DiagnosticsSetting(onIntent) } + item { DiagnosticsSetting(state, onIntent) } item { AboutSetting(onIntent) } } } @Composable -private fun DiagnosticsSetting(onIntent: (MainIntent) -> Unit) { +private fun DiagnosticsSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { + var confirmClear by rememberSaveable { mutableStateOf(false) } SensorBoxPanel { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Text(stringResource(R.string.diagnostics_title), style = MaterialTheme.typography.titleMedium) @@ -79,22 +88,85 @@ private fun DiagnosticsSetting(onIntent: (MainIntent) -> Unit) { stringResource(R.string.diagnostics_summary), color = MaterialTheme.colorScheme.onSurfaceVariant, ) + SensorBoxSecondaryButton( + label = stringResource(R.string.diagnostics_view), + onClick = { onIntent(SettingsIntent.ViewDiagnostics) }, + modifier = Modifier.fillMaxWidth(), + ) + SensorBoxSecondaryButton( + label = stringResource(R.string.diagnostics_copy), + onClick = { onIntent(SettingsIntent.CopyDiagnostics) }, + modifier = Modifier.fillMaxWidth(), + ) SensorBoxSecondaryButton( label = stringResource(R.string.diagnostics_share_text), - onClick = { onIntent(MainIntent.ShareDiagnosticsText) }, + onClick = { onIntent(SettingsIntent.ShareDiagnosticsText) }, modifier = Modifier.fillMaxWidth(), ) SensorBoxSecondaryButton( label = stringResource(R.string.diagnostics_share_file), - onClick = { onIntent(MainIntent.ShareDiagnosticsFile) }, + onClick = { onIntent(SettingsIntent.ShareDiagnosticsFile) }, + modifier = Modifier.fillMaxWidth(), + ) + SensorBoxSecondaryButton( + label = stringResource(R.string.diagnostics_clear), + onClick = { confirmClear = true }, modifier = Modifier.fillMaxWidth(), ) } } + + state.diagnosticsText?.let { diagnostics -> + AlertDialog( + onDismissRequest = { onIntent(SettingsIntent.DismissDiagnostics) }, + title = { Text(stringResource(R.string.diagnostics_title)) }, + text = { + SelectionContainer { + Text( + text = diagnostics, + modifier = Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState()), + ) + } + }, + confirmButton = { + TextButton(onClick = { onIntent(SettingsIntent.CopyDiagnostics) }) { + Text(stringResource(R.string.diagnostics_copy)) + } + }, + dismissButton = { + TextButton(onClick = { onIntent(SettingsIntent.DismissDiagnostics) }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } + + if (confirmClear) { + AlertDialog( + onDismissRequest = { confirmClear = false }, + title = { Text(stringResource(R.string.diagnostics_clear)) }, + text = { Text(stringResource(R.string.diagnostics_clear_confirmation)) }, + confirmButton = { + TextButton( + onClick = { + confirmClear = false + onIntent(SettingsIntent.ClearDiagnostics) + }, + ) { + Text(stringResource(R.string.diagnostics_clear)) + } + }, + dismissButton = { + TextButton(onClick = { confirmClear = false }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } } @Composable -private fun GpsSettings(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun GpsSettings(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { NumberPickerSetting( stringResource(R.string.gps_interval), @@ -106,7 +178,7 @@ private fun GpsSettings(state: MainState, onIntent: (MainIntent) -> Unit) { ), 1, 3_600, - ) { onIntent(MainIntent.SetGpsInterval(it)) } + ) { onIntent(SettingsIntent.SetGpsInterval(it)) } NumberPickerSetting( stringResource(R.string.gps_minimum_distance), state.preferences.gpsMinDistanceMeters, @@ -117,7 +189,7 @@ private fun GpsSettings(state: MainState, onIntent: (MainIntent) -> Unit) { ), 0, 10_000, - ) { onIntent(MainIntent.SetGpsDistance(it)) } + ) { onIntent(SettingsIntent.SetGpsDistance(it)) } } } @@ -189,34 +261,34 @@ private fun NumberPickerSetting( } @Composable -private fun BatteryGuardSetting(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun BatteryGuardSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.battery_guard), description = stringResource(R.string.battery_guard_settings_description), checked = state.preferences.restrictMeasurementOnLowBattery, - ) { onIntent(MainIntent.SetLowBatteryRestriction(it)) } + ) { onIntent(SettingsIntent.SetLowBatteryRestriction(it)) } } @Composable -private fun CpuWakeLockSetting(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun CpuWakeLockSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.keep_cpu_awake), description = stringResource(R.string.keep_cpu_awake_settings_description), checked = state.preferences.useWakeLock, - ) { onIntent(MainIntent.SetWakeLock(it)) } + ) { onIntent(SettingsIntent.SetWakeLock(it)) } } @Composable -private fun ScreenAwakeSetting(state: MainState, onIntent: (MainIntent) -> Unit) { +private fun ScreenAwakeSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { BooleanSetting( title = stringResource(R.string.keep_screen_awake), description = stringResource(R.string.keep_screen_awake_settings_description), checked = state.preferences.keepPhoneDisplayOn, - ) { onIntent(MainIntent.SetKeepScreenAwake(it)) } + ) { onIntent(SettingsIntent.SetKeepScreenAwake(it)) } } @Composable -private fun BatteryOptimizationSetting(isExempt: Boolean, onIntent: (MainIntent) -> Unit) { +private fun BatteryOptimizationSetting(isExempt: Boolean, onIntent: (SettingsIntent) -> Unit) { SensorBoxPanel { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Text(stringResource(R.string.battery_optimization), style = MaterialTheme.typography.titleMedium) @@ -229,7 +301,7 @@ private fun BatteryOptimizationSetting(isExempt: Boolean, onIntent: (MainIntent) if (!isExempt) { SensorBoxSecondaryButton( label = stringResource(R.string.exclude_from_battery_saving), - onClick = { onIntent(MainIntent.RequestBatteryOptimizationExemption) }, + onClick = { onIntent(SettingsIntent.RequestBatteryOptimizationExemption) }, modifier = Modifier.fillMaxWidth(), ) } @@ -238,7 +310,7 @@ private fun BatteryOptimizationSetting(isExempt: Boolean, onIntent: (MainIntent) } @Composable -private fun AboutSetting(onIntent: (MainIntent) -> Unit) { +private fun AboutSetting(onIntent: (SettingsIntent) -> Unit) { var showAboutDialog by rememberSaveable { mutableStateOf(false) } SensorBoxPanel { @@ -261,11 +333,11 @@ private fun AboutSetting(onIntent: (MainIntent) -> Unit) { onDismiss = { showAboutDialog = false }, onPrivacy = { showAboutDialog = false - onIntent(MainIntent.Navigate(MainRoute.PRIVACY)) + onIntent(SettingsIntent.Navigate(MainRoute.PRIVACY)) }, onLicenses = { showAboutDialog = false - onIntent(MainIntent.Navigate(MainRoute.LICENSES)) + onIntent(SettingsIntent.Navigate(MainRoute.LICENSES)) }, ) } @@ -291,7 +363,7 @@ private fun Context.isBatteryOptimizationExempt(): Boolean = getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(packageName) @Composable -fun SamplingSetting(selected: Int, onIntent: (MainIntent) -> Unit) { +fun SamplingSetting(selected: Int, onSamplingPeriod: (Int) -> Unit) { SensorBoxPanel { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Text(stringResource(R.string.sensor_sampling), style = MaterialTheme.typography.titleMedium) @@ -307,7 +379,7 @@ fun SamplingSetting(selected: Int, onIntent: (MainIntent) -> Unit) { R.string.sampling_normal, ).forEachIndexed { index, label -> SamplingChip(stringResource(label), selected == index) { - onIntent(MainIntent.SetSamplingPeriod(index)) + onSamplingPeriod(index) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 77d5c1f..42222f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -228,5 +228,12 @@ Share diagnostics Send as text Send as file + View logs + Copy logs + Clear logs + Delete both retained diagnostic log files? This cannot be undone. + Diagnostics copied. + Diagnostics cleared. + The diagnostics action failed. Diagnostics could not be shared. The error was saved for a later attempt. diff --git a/app/src/test/java/com/motionapps/sensorbox/PolicyLinksTest.kt b/app/src/test/java/com/motionapps/sensorbox/PolicyLinksTest.kt index 5774387..34a824a 100644 --- a/app/src/test/java/com/motionapps/sensorbox/PolicyLinksTest.kt +++ b/app/src/test/java/com/motionapps/sensorbox/PolicyLinksTest.kt @@ -1,9 +1,9 @@ package com.motionapps.sensorbox -import java.io.File -import javax.xml.parsers.DocumentBuilderFactory import org.junit.Assert.assertEquals import org.junit.Test +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory class PolicyLinksTest { @Test diff --git a/app/src/test/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt b/app/src/test/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt new file mode 100644 index 0000000..f2bd197 --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt @@ -0,0 +1,223 @@ +package com.motionapps.sensorbox.domain.paired + +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.DiagnosticLogger +import com.motionapps.sensorbox.domain.measurement.MeasurementRequest +import com.motionapps.sensorbox.domain.measurement.PhoneRecordingController +import com.motionapps.sensorbox.domain.measurement.PreparedPhoneRecording +import com.motionapps.sensorservices.intent.MeasurementLaunchRequest +import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase +import com.motionapps.wearoslib.connectivity.WearConnectionRepository +import com.motionapps.wearoslib.connectivity.WearNode +import com.motionapps.wearoslib.protocol.SendWearCommandUseCase +import com.motionapps.wearoslib.protocol.WearAcknowledgementOutcome +import com.motionapps.wearoslib.protocol.WearCommand +import com.motionapps.wearoslib.protocol.WearCommandCodec +import com.motionapps.wearoslib.protocol.WearSessionCommand +import com.motionapps.wearoslib.protocol.WearStopReason +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PairedRecordingCoordinatorTest { + @Test + fun `Given Wear rejects prepare When start is requested Then local preparation is aborted`() = runTest { + val fixture = Fixture() + fixture.repository.onCommand = { command -> + when (command) { + is WearCommand.PrepareRecording -> fixture.ack( + command.sessionId, + WearSessionCommand.PREPARE, + WearAcknowledgementOutcome.REJECTED, + AppErrorCode.PERMISSION, + ) + + is WearCommand.AbortRecording -> fixture.succeed(command.sessionId, WearSessionCommand.ABORT) + + else -> Unit + } + } + + val result = fixture.coordinator.start(pairedRequest()) + + assertTrue(result.isFailure) + assertEquals(1, fixture.local.abortCalls) + assertTrue(fixture.repository.commands.any { it is WearCommand.AbortRecording }) + assertEquals(0, fixture.local.commitCalls) + } + + @Test + fun `Given no prepare ack When start times out Then three sends and both sides abort`() = runTest { + val fixture = Fixture() + fixture.repository.onCommand = { command -> + if (command is WearCommand.AbortRecording) { + fixture.succeed(command.sessionId, WearSessionCommand.ABORT) + } + } + + val result = fixture.coordinator.start(pairedRequest()) + + assertEquals(AppErrorCode.TIMEOUT, result.errorOrNull()?.code) + assertEquals(3, fixture.repository.commands.count { it is WearCommand.PrepareRecording }) + assertEquals(1, fixture.local.abortCalls) + } + + @Test + fun `Given commit is rejected When local commit succeeded Then both devices are compensated`() = runTest { + val fixture = Fixture() + fixture.repository.onCommand = { command -> + when (command) { + is WearCommand.PrepareRecording -> fixture.succeed(command.sessionId, WearSessionCommand.PREPARE) + + is WearCommand.CommitRecording -> fixture.ack( + command.sessionId, + WearSessionCommand.COMMIT, + WearAcknowledgementOutcome.FAILED, + AppErrorCode.MEASUREMENT, + ) + + is WearCommand.AbortRecording -> fixture.succeed(command.sessionId, WearSessionCommand.ABORT) + + else -> Unit + } + } + + val result = fixture.coordinator.start(pairedRequest()) + + assertTrue(result.isFailure) + assertEquals(1, fixture.local.commitCalls) + assertEquals(1, fixture.local.abortCalls) + assertTrue(fixture.repository.commands.any { it is WearCommand.AbortRecording }) + } + + @Test + fun `Given local stop fails When paired recording stops Then remote stop is still attempted`() = runTest { + val fixture = Fixture() + fixture.repository.onCommand = { command -> + when (command) { + is WearCommand.PrepareRecording -> fixture.succeed(command.sessionId, WearSessionCommand.PREPARE) + is WearCommand.CommitRecording -> fixture.succeed(command.sessionId, WearSessionCommand.COMMIT) + is WearCommand.StopRecording -> fixture.succeed(command.sessionId, WearSessionCommand.STOP) + else -> Unit + } + } + assertTrue(fixture.coordinator.start(pairedRequest()).isSuccess) + fixture.local.stopResult = failure("Stop local recording") + + val result = fixture.coordinator.stop() + + assertTrue(result.isFailure) + assertEquals(1, fixture.local.stopCalls) + assertTrue(fixture.repository.commands.any { it is WearCommand.StopRecording }) + } + + private fun pairedRequest() = MeasurementRequest( + sensorIds = setOf(1), + includesGps = false, + samplingPeriodIndex = 0, + stopOnLowBattery = true, + useWakeLock = true, + gpsIntervalSeconds = 10, + gpsMinDistanceMeters = 20, + wearSensorIds = setOf(21), + ) + + private class Fixture { + val inbox = WearAcknowledgementInbox() + val repository = InteractiveRepository() + val local = FakePhoneRecordingController() + val coordinator = PairedRecordingCoordinator( + localController = local, + sendCommand = SendWearCommandUseCase(SendWearMessageUseCase(repository)), + acknowledgementInbox = inbox, + sessionIdFactory = RecordingSessionIdFactory(), + diagnosticLogger = DiagnosticLogger { }, + ) + + fun succeed(sessionId: String, command: WearSessionCommand) { + ack(sessionId, command, WearAcknowledgementOutcome.SUCCEEDED) + } + + fun ack( + sessionId: String, + command: WearSessionCommand, + outcome: WearAcknowledgementOutcome, + errorCode: AppErrorCode? = null, + ) { + inbox.publish(WearCommand.Acknowledgement(sessionId, command, outcome, errorCode)) + } + } +} + +private class InteractiveRepository : WearConnectionRepository { + val commands = mutableListOf() + var onCommand: (WearCommand) -> Unit = { } + + override fun observeCapability(capability: String): Flow = + emptyFlow() + + override suspend fun findNode(capability: String): WearNode? = WearNode("wear", "Wear", isNearby = true) + + override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult { + val command = WearCommandCodec.decode(payload).getOrThrow() + commands += command + onCommand(command) + return AppResult.success(Unit) + } +} + +private class FakePhoneRecordingController : PhoneRecordingController { + var commitCalls = 0 + var abortCalls = 0 + var stopCalls = 0 + var stopResult: AppResult = AppResult.success(Unit) + + override suspend fun prepare(sessionId: String, request: MeasurementRequest): AppResult = + AppResult.success( + PreparedPhoneRecording( + sessionId = sessionId, + launchRequest = MeasurementLaunchRequest( + sessionId = sessionId, + folderName = "fixture", + useInternalStorage = false, + sensorIds = request.sensorIds, + sensorSamplingPeriod = 0, + includesGps = request.includesGps, + stopOnLowBattery = request.stopOnLowBattery, + useWakeLock = request.useWakeLock, + gpsIntervalSeconds = request.gpsIntervalSeconds, + gpsMinDistanceMeters = request.gpsMinDistanceMeters, + ), + ), + ) + + override fun commit(prepared: PreparedPhoneRecording, startAtEpochMillis: Long): AppResult { + commitCalls += 1 + return AppResult.success(Unit) + } + + override fun abort(sessionId: String): AppResult { + abortCalls += 1 + return AppResult.success(Unit) + } + + override fun stop(sessionId: String, reason: WearStopReason): AppResult { + stopCalls += 1 + return stopResult + } + + override fun stopAny(reason: WearStopReason): AppResult = stopResult + + override fun annotate(text: String, timestampMillis: Long): AppResult = AppResult.success(Unit) +} + +private fun failure(operation: String): AppResult = AppResult.failure( + AppError(AppErrorCode.MEASUREMENT, operation), +) diff --git a/app/src/test/java/com/motionapps/sensorbox/domain/paired/PhoneWearMessageDispatcherTest.kt b/app/src/test/java/com/motionapps/sensorbox/domain/paired/PhoneWearMessageDispatcherTest.kt new file mode 100644 index 0000000..207dc92 --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/domain/paired/PhoneWearMessageDispatcherTest.kt @@ -0,0 +1,51 @@ +package com.motionapps.sensorbox.domain.paired + +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.DiagnosticLogger +import com.motionapps.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH +import com.motionapps.wearoslib.protocol.WearCommand +import com.motionapps.wearoslib.protocol.WearCommandCodec +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PhoneWearMessageDispatcherTest { + @Test + fun `Given encoded command bytes When dispatched Then no Google callback type is required`() = runTest { + val commands = mutableListOf() + val dispatcher = PhoneWearMessageDispatcher( + commandHandler = PhoneWearCommandPolicy { command -> + commands += command + AppResult.success(Unit) + }, + diagnosticLogger = DiagnosticLogger { }, + ) + val payload = WearCommandCodec.encode(WearCommand.LaunchPhone).getOrThrow() + + val result = dispatcher.dispatch(PHONE_MESSAGE_PATH, payload) + + assertTrue(result.isSuccess) + assertEquals(listOf(WearCommand.LaunchPhone), commands) + } + + @Test + fun `Given a protocol v1 payload When dispatched Then command policy is not called`() = runTest { + var calls = 0 + val dispatcher = PhoneWearMessageDispatcher( + commandHandler = PhoneWearCommandPolicy { + calls += 1 + AppResult.success(Unit) + }, + diagnosticLogger = DiagnosticLogger { }, + ) + + val result = dispatcher.dispatch( + PHONE_MESSAGE_PATH, + byteArrayOf(0x53, 0x42, 0x58, 0x31, 1, 1), + ) + + assertTrue(result.isFailure) + assertEquals(0, calls) + } +} diff --git a/app/src/test/java/com/motionapps/sensorbox/presentation/main/FeatureViewModelTest.kt b/app/src/test/java/com/motionapps/sensorbox/presentation/main/FeatureViewModelTest.kt new file mode 100644 index 0000000..b0cbf75 --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/presentation/main/FeatureViewModelTest.kt @@ -0,0 +1,168 @@ +package com.motionapps.sensorbox.presentation.main + +import android.content.Intent +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.DiagnosticsStore +import com.motionapps.sensorbox.core.testing.FakeAppPreferencesRepository +import com.motionapps.sensorbox.domain.measurement.DocumentStorageGateway +import com.motionapps.sensorbox.domain.measurement.MeasurementRequest +import com.motionapps.sensorbox.domain.measurement.RecordingWorkflowGateway +import com.motionapps.sensorbox.domain.sensors.SensorDescriptor +import com.motionapps.sensorbox.domain.sensors.WearSensorCatalogStore +import com.motionapps.sensorbox.testing.MainDispatcherRule +import com.motionapps.sensorservices.session.MeasurementSessionStore +import com.motionapps.wearoslib.connectivity.FakeWearConnectionRepository +import com.motionapps.wearoslib.connectivity.ObserveWearCapabilityUseCase +import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +class FeatureViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `Given onboarding storage When completed Then state and navigation effect are owned by onboarding`() = runTest { + val viewModel = OnboardingViewModel( + preferencesRepository = FakeAppPreferencesRepository(), + documentStorage = FakeDocumentStorage(hasStorage = true), + ) + val effect = async { viewModel.effects.first() } + + viewModel.accept(OnboardingIntent.AdvanceOnboarding) + viewModel.accept(OnboardingIntent.CompleteOnboarding) + advanceUntilIdle() + + assertEquals(1, viewModel.state.value.page) + assertEquals(OnboardingEffect.Navigate(MainRoute.RECORD), effect.await()) + } + + @Test + fun `Given onboarding has no storage When completed Then storage error code is exposed`() = runTest { + val viewModel = OnboardingViewModel( + preferencesRepository = FakeAppPreferencesRepository(), + documentStorage = FakeDocumentStorage(hasStorage = false), + ) + + viewModel.accept(OnboardingIntent.CompleteOnboarding) + + assertEquals(AppErrorCode.STORAGE, viewModel.state.value.errorCode) + } + + @Test + fun `Given settings action When sampling changes Then settings owns updated preference state`() = runTest { + val viewModel = SettingsViewModel( + preferencesRepository = FakeAppPreferencesRepository(), + diagnosticsStore = FakeDiagnosticsStore(), + ioDispatcher = mainDispatcherRule.dispatcher, + ) + advanceUntilIdle() + + viewModel.accept(SettingsIntent.SetSamplingPeriod(3)) + advanceUntilIdle() + + assertEquals(3, viewModel.state.value.preferences.sensorSamplingPeriod) + } + + @Test + fun `Given diagnostics read failure When viewed Then settings emits the stable error code`() = runTest { + val diagnostics = FakeDiagnosticsStore( + readResult = AppResult.failure(AppError(AppErrorCode.STORAGE, "Read fixture diagnostics")), + ) + val viewModel = SettingsViewModel( + FakeAppPreferencesRepository(), + diagnostics, + mainDispatcherRule.dispatcher, + ) + val effect = async { viewModel.effects.first() } + + viewModel.accept(SettingsIntent.ViewDiagnostics) + advanceUntilIdle() + + assertEquals(AppErrorCode.STORAGE, viewModel.state.value.errorCode) + assertEquals(SettingsEffect.DiagnosticsFailed(AppErrorCode.STORAGE), effect.await()) + } + + @Test + fun `Given no selected source When recording starts Then recording exposes validation code`() = runTest { + val viewModel = recordingViewModel(FakeRecordingWorkflow()) + advanceUntilIdle() + + viewModel.accept(RecordingIntent.StartMeasurement) + + assertEquals(RecordingMessage.PICK_AT_LEAST_ONE_SOURCE, viewModel.state.value.message) + assertEquals(AppErrorCode.VALIDATION, viewModel.state.value.errorCode) + } + + @Test + fun `Given storage action When chosen Then recording emits only its picker effect`() = runTest { + val viewModel = recordingViewModel(FakeRecordingWorkflow()) + val effect = async { viewModel.effects.first() } + + viewModel.accept(RecordingIntent.ChooseStorage) + + assertEquals(RecordingEffect.PickStorageDirectory, effect.await()) + } + + private fun recordingViewModel(workflow: RecordingWorkflowGateway): RecordingViewModel { + val repository = FakeWearConnectionRepository() + return RecordingViewModel( + preferencesRepository = FakeAppPreferencesRepository(), + workflow = workflow, + sessionStore = MeasurementSessionStore(), + observeWearCapability = ObserveWearCapabilityUseCase(repository), + sendWearMessage = SendWearMessageUseCase(repository), + wearSensorCatalog = WearSensorCatalogStore(), + ) + } +} + +private class FakeDocumentStorage(private val hasStorage: Boolean) : DocumentStorageGateway { + override fun hasStorage(): AppResult = AppResult.success(hasStorage) + + override fun displayPath(): AppResult = AppResult.success(if (hasStorage) "fixture" else null) + + override fun persist(resultIntent: Intent): AppResult = AppResult.success(Unit) +} + +private class FakeDiagnosticsStore( + private val readResult: AppResult = AppResult.success("fixture diagnostics"), +) : DiagnosticsStore { + override fun readText(): AppResult = readResult + + override fun exportFile(): AppResult = AppResult.failure( + AppError(AppErrorCode.STORAGE, "Export fixture diagnostics"), + ) + + override fun clear(): AppResult = AppResult.success(Unit) +} + +private class FakeRecordingWorkflow : RecordingWorkflowGateway { + var startResult: AppResult = AppResult.success(Unit) + + override fun sensors(): List = emptyList() + + override fun storagePath(): String? = "fixture" + + override fun hasStorage(): Boolean = true + + override fun persistStorage(resultIntent: Intent?): AppResult = AppResult.success(Unit) + + override fun missingPermissions(request: MeasurementRequest, includesHeartRate: Boolean): Set = emptySet() + + override suspend fun start(request: MeasurementRequest): AppResult = startResult + + override suspend fun stop(): AppResult = AppResult.success(Unit) + + override fun annotate(text: String): AppResult = AppResult.success(Unit) +} diff --git a/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingReducerTest.kt b/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingReducerTest.kt new file mode 100644 index 0000000..0aa4a52 --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingReducerTest.kt @@ -0,0 +1,82 @@ +package com.motionapps.sensorbox.presentation.main + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecordingReducerTest { + @Test + fun `Given an unselected sensor When toggled Then it becomes selected`() { + val givenState = RecordingStateFixtures.state() + + val actual = RecordingReducer.reduce(givenState, RecordingIntent.ToggleSensor(sensorId = 1)) + + assertTrue(1 in actual.state.selectedSensorIds) + } + + @Test + fun `Given record state When storage is requested Then picker effect is emitted`() { + val givenState = RecordingStateFixtures.state() + + val actual = RecordingReducer.reduce(givenState, RecordingIntent.ChooseStorage) + + assertEquals(RecordingEffect.PickStorageDirectory, actual.effect) + } + + @Test + fun `Given GPS disabled When toggled Then GPS becomes enabled`() { + val givenState = RecordingStateFixtures.state(includesGps = false) + + val actual = RecordingReducer.reduce(givenState, RecordingIntent.ToggleGps) + + assertTrue(actual.state.includesGps) + } + + @Test + fun `Given an unselected Wear sensor When toggled Then only Wear selection changes`() { + val givenState = RecordingStateFixtures.state() + + val actual = RecordingReducer.reduce(givenState, RecordingIntent.ToggleWearSensor(sensorId = 21)) + + assertTrue(21 in actual.state.selectedWearSensorIds) + assertTrue(actual.state.selectedSensorIds.isEmpty()) + } + + @Test + fun `Given endless mode When timed mode is selected Then timing configuration is retained`() { + val givenState = RecordingStateFixtures.state().copy(durationSeconds = 60) + + val actual = RecordingReducer.reduce(givenState, RecordingIntent.SetMeasurementType("TIMED")) + + assertEquals("TIMED", actual.state.measurementType) + assertEquals(60, actual.state.durationSeconds) + } + + @Test + fun `Given selected sensors When setup is opened Then setup route is shown`() { + val givenState = RecordingStateFixtures.state(selectedSensorIds = setOf(1)) + + val actual = RecordingReducer.reduce(givenState, RecordingIntent.OpenMeasurementSetup) + + assertEquals(RecordingEffect.Navigate(MainRoute.SETUP), actual.effect) + } + + @Test + fun `Given a sensor When its information is opened Then details route retains its type`() { + val givenState = RecordingStateFixtures.state() + + val actual = RecordingReducer.reduce(givenState, RecordingIntent.OpenSensorDetails(sensorType = 1)) + + assertEquals(RecordingEffect.Navigate(MainRoute.SENSOR_DETAILS), actual.effect) + assertEquals(1, actual.state.detailsSensorType) + } + + @Test + fun `Given measurement setup When returning Then sensor selection is shown`() { + val givenState = RecordingStateFixtures.state(selectedSensorIds = setOf(1)) + + val actual = RecordingReducer.reduce(givenState, RecordingIntent.ReturnToSensorSelection) + + assertEquals(RecordingEffect.Navigate(MainRoute.RECORD), actual.effect) + } +} diff --git a/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateFixtures.kt b/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateFixtures.kt new file mode 100644 index 0000000..e79bd86 --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/presentation/main/RecordingStateFixtures.kt @@ -0,0 +1,13 @@ +package com.motionapps.sensorbox.presentation.main + +import com.motionapps.sensorbox.domain.sensors.SensorDescriptor + +object RecordingStateFixtures { + fun state(selectedSensorIds: Set = emptySet(), includesGps: Boolean = false) = RecordingState( + sensors = listOf( + SensorDescriptor(type = 1, name = "Accelerometer", vendor = "Fixture", isHeartRate = false), + ), + selectedSensorIds = selectedSensorIds, + includesGps = includesGps, + ) +} diff --git a/app/src/test/java/com/motionapps/sensorbox/testing/MainDispatcherRule.kt b/app/src/test/java/com/motionapps/sensorbox/testing/MainDispatcherRule.kt new file mode 100644 index 0000000..38c9e27 --- /dev/null +++ b/app/src/test/java/com/motionapps/sensorbox/testing/MainDispatcherRule.kt @@ -0,0 +1,21 @@ +package com.motionapps.sensorbox.testing + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule(val dispatcher: TestDispatcher = StandardTestDispatcher()) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +}