diff --git a/README.md b/README.md index 846400d..d1886a7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This is the hard-cut Android 17 generation of the project. It does not retain th | Sensor selection | Measurement setup | |:---:|:---:| -| SensorBox sensor selection with original icons | SensorBox measurement setup | +| SensorBox source selection | SensorBox measurement setup | | Wear dashboard | Wear live-sensor picker | |:---:|:---:| @@ -24,7 +24,13 @@ This is the hard-cut Android 17 generation of the project. It does not retain th |:---:|:---:|:---:| | SensorBox Android lifecycle introduction | SensorBox battery optimization introduction | SensorBox recording-folder introduction | -The introduction uses the original repository artwork. Privacy Policy, Terms of Use, battery optimization, and folder selection are live native actions. Folder selection remains mandatory before setup can finish. +The introduction uses tintable vector illustrations that follow the app theme. Privacy Policy, Terms of Use, battery optimization, and folder selection are live native actions. Folder selection remains mandatory before setup can finish. + +All screenshots above come from deterministic Compose preview fixtures. Refresh the complete gallery on the host without an emulator or connected device: + +```shell +./gradlew refreshReadmeScreenshots +``` ## Current feature set @@ -59,7 +65,7 @@ Paired phone/watch recording is all-or-nothing: both sides prepare before either ## Platform and toolchain -- Android Gradle Plugin 9.3.1 and Gradle 9.7 +- Android Gradle Plugin 9.3.2 and Gradle 9.7 - Android compile/target SDK 37 (Android 17) - Java 17 and Kotlin 2.4.10 - Jetpack Compose Material 3 and Wear Compose Material 3 @@ -77,6 +83,7 @@ Install JDK 17 and Android SDK 37, then run: ./gradlew :app:assembleDebug :wear:assembleDebug ./gradlew testDebugUnitTest detekt ./gradlew :app:lintDebug :wear:lintDebug +./gradlew :app:validateDebugScreenshotTest :wear:validateDebugScreenshotTest ``` Instrumentation test sources can be compiled without a device: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5e8b7de..1945daa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,11 +6,14 @@ plugins { alias(libs.plugins.ksp) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.oss.licenses) + alias(libs.plugins.screenshot) } android { + experimentalProperties["android.experimental.enableScreenshotTest"] = true namespace = "com.motionapps.sensorbox" compileSdk = 37 + sourceSets["main"].res.directories.add(rootProject.file("shared-resources/branding").path) defaultConfig { applicationId = "motionapps.sensorbox" @@ -93,6 +96,9 @@ dependencies { androidTestImplementation(testFixtures(project(":wearoslib"))) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) + screenshotTestImplementation(platform(libs.androidx.compose.bom)) + screenshotTestImplementation(libs.androidx.compose.ui.tooling) + screenshotTestImplementation(libs.screenshot.validation.api) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f32032c..62e169e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -23,9 +23,9 @@ Unit, onPrivacy: () -> Unit, onLicenses: () -> onDismissRequest = onDismiss, icon = { Image( - painter = painterResource(R.drawable.ic_launcher_historic_round), + painter = painterResource(R.drawable.ic_sensorbox_logo), contentDescription = null, modifier = Modifier.size(64.dp), ) @@ -89,12 +87,11 @@ fun OpenSourceLicensesScreen(onBack: () -> Unit, modifier: Modifier = Modifier) } var selectedLicense by remember { mutableStateOf(null) } - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(start = 20.dp, top = 12.dp, end = 20.dp, bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + SensorBoxBackScreen( + title = stringResource(R.string.about_licenses), + onBack = onBack, + modifier = modifier, ) { - item { SensorBoxTopAppBar(stringResource(R.string.about_licenses), onBack) } items(licenses, key = OpenSourceLicense::name) { license -> Surface( modifier = Modifier.fillMaxWidth().clickable { selectedLicense = license }, 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 09bc8a4..0aa9fdb 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 @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll @@ -45,6 +46,7 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -57,44 +59,47 @@ import com.motionapps.sensorservices.session.MeasurementSessionState 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), - horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.fillMaxSize(), ) { - RecordingHeader() + RecordingHeader(Modifier.padding(start = 24.dp, top = 8.dp, end = 24.dp)) Column( - modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), ) { - Spacer(Modifier.height(48.dp)) MeasurementTimer(state.elapsedSeconds, session.folderName) - Spacer(Modifier.height(36.dp)) + SensorBoxSettingsDivider() MeasurementSummary(state, session) - Spacer(Modifier.height(24.dp)) + SensorBoxSettingsSection(stringResource(R.string.measurement_markers_category)) AnnotationEditor(onIntent) Spacer(Modifier.height(24.dp)) } - SensorBoxDangerButton( - label = stringResource(R.string.stop_and_save), - onClick = { onIntent(RecordingIntent.StopMeasurement) }, - modifier = Modifier.fillMaxWidth(), - ) + ActiveMeasurementActionBar(onIntent) } } @Composable private fun AnnotationEditor(onIntent: (RecordingIntent) -> Unit) { var annotation by remember { mutableStateOf("") } - SensorBoxPanel { - Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - OutlinedTextField( - value = annotation, - onValueChange = { annotation = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.annotation)) }, - supportingText = { Text(stringResource(R.string.annotation_description)) }, - singleLine = true, - ) - SensorBoxSecondaryButton( + Column( + Modifier.fillMaxWidth().padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = stringResource(R.string.annotation_description), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + value = annotation, + onValueChange = { annotation = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.annotation)) }, + singleLine = true, + ) + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + SensorBoxPrimaryButton( label = stringResource(R.string.add_annotation), onClick = { annotation.trim().takeIf(String::isNotEmpty)?.let { @@ -102,15 +107,18 @@ private fun AnnotationEditor(onIntent: (RecordingIntent) -> Unit) { annotation = "" } }, + modifier = Modifier.widthIn(min = 152.dp, max = 196.dp), + enabled = annotation.isNotBlank(), ) } } + SensorBoxSettingsDivider() } @Composable -private fun RecordingHeader() { +private fun RecordingHeader(modifier: Modifier = Modifier) { Row( - modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + modifier = modifier.fillMaxWidth().height(64.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { @@ -160,11 +168,14 @@ private fun RecordingIndicator() { @Composable private fun MeasurementTimer(elapsedSeconds: Long, folderName: String) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Text(stringResource(R.string.elapsed_time), color = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.height(10.dp)) + Spacer(Modifier.height(4.dp)) Text(ValueFormats.elapsedSeconds(elapsedSeconds), style = MaterialTheme.typography.displayLarge) - Text(folderName, color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Center) + Text(folderName, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) } } @@ -172,45 +183,37 @@ private fun MeasurementTimer(elapsedSeconds: Long, folderName: String) { private fun MeasurementSummary(state: RecordingState, session: MeasurementSessionState.Running) { var expanded by remember { mutableStateOf(false) } val sources = recordingSourceNames(state, session, LocalContext.current.resources) - SensorBoxPanel { - Column( - Modifier.fillMaxWidth().padding(18.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + Column(Modifier.fillMaxWidth()) { + SourceListToggle(sourceCount = sources.size, expanded = expanded, onClick = { expanded = !expanded }) + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), ) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { - SummaryValue(sources.size.toString(), stringResource(R.string.sources)) - SummaryValue( - stringResource(if (session.includesGps) R.string.on else R.string.off), - stringResource(R.string.gps), - ) - } - SensorListToggle(expanded = expanded, onClick = { expanded = !expanded }) - AnimatedVisibility( - visible = expanded, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - RecordingSourceList(sources) - } + RecordingSourceList(sources) } + SensorBoxSettingsDivider() } } @Composable -private fun SensorListToggle(expanded: Boolean, onClick: () -> Unit) { +private fun SourceListToggle(sourceCount: Int, expanded: Boolean, onClick: () -> Unit) { val arrowRotation by animateFloatAsState( targetValue = if (expanded) 180f else 0f, label = "sensor list arrow", ) Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 6.dp), + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = stringResource(R.string.sensor_list), - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.titleMedium, - ) + Column(Modifier.weight(1f)) { + Text(text = stringResource(R.string.sources), style = MaterialTheme.typography.titleMedium) + Text( + text = pluralStringResource(R.plurals.source_count, sourceCount, sourceCount), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } Icon( painter = painterResource(R.drawable.ic_expand_more_24), contentDescription = stringResource(if (expanded) R.string.hide_sensor_list else R.string.show_sensor_list), @@ -222,10 +225,14 @@ private fun SensorListToggle(expanded: Boolean, onClick: () -> Unit) { @Composable private fun RecordingSourceList(sources: List) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column { HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) sources.forEach { source -> - Text(source, style = MaterialTheme.typography.bodyLarge) + Text( + source, + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + style = MaterialTheme.typography.bodyLarge, + ) } } } @@ -245,10 +252,16 @@ private fun recordingSourceNames( } @Composable -private fun SummaryValue(value: String, label: String) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(value, style = MaterialTheme.typography.headlineMedium) - Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant) +private fun ActiveMeasurementActionBar(onIntent: (RecordingIntent) -> Unit) { + Box( + Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + SensorBoxDangerButton( + label = stringResource(R.string.stop_and_save), + onClick = { onIntent(RecordingIntent.StopMeasurement) }, + modifier = Modifier.widthIn(min = 176.dp, max = 240.dp), + ) } } diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/DiagnosticsLogScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/DiagnosticsLogScreen.kt new file mode 100644 index 0000000..5df4b23 --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/DiagnosticsLogScreen.kt @@ -0,0 +1,41 @@ +package com.motionapps.sensorbox.presentation.main + +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import com.motionapps.sensorbox.R + +@Composable +fun DiagnosticsLogScreen( + state: SettingsState, + onBack: () -> Unit, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + LaunchedEffect(Unit) { onRefresh() } + SensorBoxBackScreen( + title = stringResource(R.string.diagnostics_logs_title), + onBack = onBack, + modifier = modifier, + ) { + item { + SelectionContainer { + Text( + text = when { + !state.diagnosticsLoaded -> stringResource(R.string.diagnostics_loading) + state.diagnosticsText.isNullOrBlank() -> stringResource(R.string.diagnostics_empty) + else -> state.diagnosticsText + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + fontFamily = FontFamily.Monospace, + ) + } + } + } +} diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainContract.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainContract.kt index f8bfd2c..93ecc85 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainContract.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainContract.kt @@ -13,6 +13,7 @@ enum class MainRoute : NavKey { SENSOR_PREVIEW, SETUP, SETTINGS, + DIAGNOSTICS, LICENSES, PRIVACY, } 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 d631376..f8bd18a 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 @@ -3,17 +3,13 @@ package com.motionapps.sensorbox.presentation.main import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.shape.CircleShape -import androidx.compose.material3.Icon +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface @@ -21,7 +17,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -42,25 +37,32 @@ fun MeasurementSetupScreen( @Composable 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), + SensorBoxBackScreen( + title = stringResource(R.string.measurement_setup), + onBack = onBack, + bottomPadding = 104.dp, + itemSpacing = 0.dp, ) { - item { SensorBoxTopAppBar(stringResource(R.string.measurement_setup), onBack) } + item { SensorBoxSettingsSection(stringResource(R.string.setup_storage_category)) } item { StorageSetupPanel(state.storagePath) { onIntent(RecordingIntent.ChooseStorage) } } + item { SensorBoxSettingsSection(stringResource(R.string.setup_details_category)) } item { MeasurementNameSetup(state, onIntent) } - item { RecordingTimingSetup(state, onIntent) } item { NotesAndAlarmsSetup(state, onIntent) } + item { SensorBoxSettingsSection(stringResource(R.string.setup_timing_category)) } + item { RecordingTimingSetup(state, onIntent) } + item { SensorBoxSettingsSection(stringResource(R.string.setup_sources_category)) } item { SamplingSetting(state.preferences.recording.sensorSamplingPeriod) { index -> onIntent(RecordingIntent.SetSamplingPeriod(index)) } } item { SpecializedSourcesSetup(state, onIntent) } + item { SensorBoxSettingsSection(stringResource(R.string.settings_recording_category)) } item { BatterySetup(state, onIntent) } item { WakeLockSetup(state, onIntent) } item { KeepScreenAwakeSetup(state, onIntent) } if (state.includesGps) { + item { SensorBoxSettingsSection(stringResource(R.string.settings_location_category)) } item { GpsIntervalSetup(state, onIntent) } item { GpsDistanceSetup(state, onIntent) } } @@ -70,21 +72,20 @@ private fun MeasurementSetupContent(state: RecordingState, onIntent: (RecordingI @Composable private fun MeasurementNameSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { - SensorBoxPanel { - OutlinedTextField( - value = state.customMeasurementName, - 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)) }, - singleLine = true, - ) - } + OutlinedTextField( + value = state.customMeasurementName, + onValueChange = { onIntent(RecordingIntent.SetCustomMeasurementName(it)) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + label = { Text(stringResource(R.string.custom_measurement_name)) }, + supportingText = { Text(stringResource(R.string.custom_measurement_name_description)) }, + singleLine = true, + ) + SensorBoxSettingsDivider() } @Composable private fun RecordingTimingSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column { StepSetting( stringResource(R.string.start_delay), state.startDelaySeconds, @@ -104,31 +105,29 @@ private fun RecordingTimingSetup(state: RecordingState, onIntent: (RecordingInte @Composable 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(RecordingIntent.SetNotes(it)) }, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.measurement_notes)) }, - supportingText = { Text(stringResource(R.string.measurement_notes_description)) }, - minLines = 2, - ) - OutlinedTextField( - value = state.alarmOffsets, - 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)) }, - singleLine = true, - ) - } - } + OutlinedTextField( + value = state.notes, + onValueChange = { onIntent(RecordingIntent.SetNotes(it)) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + label = { Text(stringResource(R.string.measurement_notes)) }, + supportingText = { Text(stringResource(R.string.measurement_notes_description)) }, + minLines = 2, + ) + SensorBoxSettingsDivider() + OutlinedTextField( + value = state.alarmOffsets, + onValueChange = { onIntent(RecordingIntent.SetAlarmOffsets(it)) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + label = { Text(stringResource(R.string.audible_alarm_offsets)) }, + supportingText = { Text(stringResource(R.string.audible_alarm_offsets_description)) }, + singleLine = true, + ) + SensorBoxSettingsDivider() } @Composable private fun SpecializedSourcesSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column { BooleanSetting( title = stringResource(R.string.activity_recognition), description = stringResource(R.string.activity_recognition_description), @@ -157,30 +156,24 @@ private fun SpecializedSourcesSetup(state: RecordingState, onIntent: (RecordingI @Composable private fun StorageSetupPanel(path: String?, onChoose: () -> Unit) { - SensorBoxPanel { - Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer) { - Icon( - painterResource(R.drawable.ic_baseline_folder), - null, - Modifier.padding(12.dp).size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } - Spacer(Modifier.width(14.dp)) - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { - Text(stringResource(R.string.recording_folder), style = MaterialTheme.typography.titleMedium) - Text( - path ?: stringResource(R.string.choose_recording_folder), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - SensorBoxSecondaryButton( - stringResource(if (path == null) R.string.choose else R.string.change), - onChoose, + Row( + Modifier.fillMaxWidth().padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(stringResource(R.string.recording_folder), style = MaterialTheme.typography.titleMedium) + Text( + path ?: stringResource(R.string.choose_recording_folder), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + Spacer(Modifier.width(14.dp)) + SensorBoxSecondaryButton( + stringResource(if (path == null) R.string.choose else R.string.change), + onChoose, + ) } + SensorBoxSettingsDivider() } @Composable @@ -251,16 +244,19 @@ private fun MeasurementSetupActionBar( modifier: Modifier = Modifier, ) { val sourceCount = setupSourceCount(state) - SensorBoxBottomAction( - title = pluralStringResource(R.plurals.source_count, sourceCount, sourceCount), - description = stringResource( - if (state.storagePath == null) R.string.folder_required else R.string.ready_to_record, - ), - buttonLabel = stringResource(R.string.start_measurement), - enabled = state.storagePath != null && sourceCount > 0, - onClick = { onIntent(RecordingIntent.StartMeasurement) }, - modifier = modifier, - ) + Surface(modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.background) { + Box( + Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + SensorBoxPrimaryButton( + label = stringResource(R.string.start_measurement), + onClick = { onIntent(RecordingIntent.StartMeasurement) }, + modifier = Modifier.widthIn(min = 176.dp, max = 240.dp), + enabled = state.storagePath != null && sourceCount > 0, + ) + } + } } private fun setupSourceCount(state: RecordingState): Int = 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 1901324..372d6e0 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 @@ -2,9 +2,15 @@ package com.motionapps.sensorbox.presentation.main import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -21,79 +27,106 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.motionapps.sensorbox.R @Composable fun OnboardingScreen(state: OnboardingState, onIntent: (OnboardingIntent) -> Unit) { val pageIndex = state.page.coerceIn(ONBOARDING_PAGES.indices) - val page = ONBOARDING_PAGES[pageIndex] BoxWithConstraints( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background), + .background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.TopCenter, ) { val isLandscape = maxWidth > maxHeight - OnboardingLayout(pageIndex, page, state, isLandscape, onIntent) + OnboardingLayout(pageIndex, state, isLandscape, onIntent) } } @Composable private fun OnboardingLayout( pageIndex: Int, - page: OnboardingPage, state: OnboardingState, isLandscape: Boolean, onIntent: (OnboardingIntent) -> Unit, ) { Column( modifier = Modifier - .widthIn(max = ONBOARDING_MAX_WIDTH) .fillMaxSize() - .padding(horizontal = 24.dp, vertical = if (isLandscape) 12.dp else 32.dp), + .padding(vertical = if (isLandscape) 12.dp else 32.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - OnboardingHeader(pageIndex, onIntent) - Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.TopCenter) { - OnboardingMessage(page, state, isLandscape, onIntent) + OnboardingHeader( + pageIndex, + onIntent, + Modifier.widthIn(max = ONBOARDING_MAX_WIDTH).fillMaxWidth().padding(horizontal = 24.dp), + ) + Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { + AnimatedOnboardingMessage(pageIndex, state, isLandscape, onIntent) } OnboardingControls( pageIndex = pageIndex, hasStorage = state.storagePath != null, isLandscape = isLandscape, onIntent = onIntent, - modifier = Modifier.padding(bottom = if (isLandscape) 0.dp else 8.dp), + modifier = Modifier + .widthIn(max = ONBOARDING_MAX_WIDTH) + .padding(start = 24.dp, end = 24.dp, bottom = if (isLandscape) 0.dp else 8.dp), ) } } @Composable -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(OnboardingIntent.RetreatOnboarding) }, - modifier = Modifier.align(Alignment.CenterStart), - ) - } - Text( - stringResource(R.string.intro_page_progress, pageIndex + 1, ONBOARDING_PAGES.size), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.align(Alignment.CenterEnd), +private fun AnimatedOnboardingMessage( + pageIndex: Int, + state: OnboardingState, + isLandscape: Boolean, + onIntent: (OnboardingIntent) -> Unit, +) { + AnimatedContent( + targetState = pageIndex, + modifier = Modifier.fillMaxSize(), + transitionSpec = { onboardingTransition(targetState > initialState) }, + contentAlignment = Alignment.Center, + label = "onboarding page", + ) { targetPageIndex -> + OnboardingMessage(ONBOARDING_PAGES[targetPageIndex], state, isLandscape, onIntent) + } +} + +private fun onboardingTransition(forward: Boolean): ContentTransform { + val enteringOffset: (Int) -> Int = { width -> if (forward) width else -width } + val leavingOffset: (Int) -> Int = { width -> if (forward) -width else width } + return slideInHorizontally(onboardingTween(), enteringOffset) + .togetherWith(slideOutHorizontally(onboardingTween(), leavingOffset)) +} + +private fun onboardingTween() = tween(ONBOARDING_TRANSITION_MILLIS, easing = FastOutSlowInEasing) + +@Composable +private fun OnboardingHeader(pageIndex: Int, onIntent: (OnboardingIntent) -> Unit, modifier: Modifier = Modifier) { + Box(modifier.fillMaxWidth().height(52.dp)) { + if (pageIndex > 0) { + SensorBoxBackButton( + label = stringResource(R.string.intro_back), + onClick = { onIntent(OnboardingIntent.RetreatOnboarding) }, + modifier = Modifier.align(Alignment.CenterStart), + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ) } } @@ -105,12 +138,17 @@ private fun OnboardingProgress(pageIndex: Int) { horizontalArrangement = Arrangement.spacedBy(10.dp), ) { ONBOARDING_PAGES.indices.forEach { index -> - val color = if (index <= pageIndex) { - MaterialTheme.colorScheme.primary + val color = if (index == pageIndex) { + MaterialTheme.colorScheme.onPrimaryContainer } else { - MaterialTheme.colorScheme.outlineVariant + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.32f) } - Box(Modifier.size(8.dp).clip(CircleShape).background(color)) + Box( + Modifier + .size(if (index == pageIndex) 10.dp else 8.dp) + .clip(CircleShape) + .background(color), + ) } } } @@ -140,8 +178,9 @@ private fun PortraitOnboardingMessage( .widthIn(max = ONBOARDING_MESSAGE_MAX_WIDTH) .fillMaxWidth() .verticalScroll(rememberScrollState()) - .padding(top = 72.dp), + .padding(horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, ) { OnboardingIllustration(page) Spacer(Modifier.height(24.dp)) @@ -159,13 +198,14 @@ private fun LandscapeOnboardingMessage( ) { Row( modifier = Modifier + .widthIn(max = ONBOARDING_MAX_WIDTH) .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp, vertical = 8.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { - OnboardingIllustration(page, containerSize = 104.dp, imageSize = 64.dp) + OnboardingIllustration(page, imageSize = 80.dp) Spacer(Modifier.width(32.dp)) Column( modifier = Modifier.widthIn(max = ONBOARDING_MESSAGE_MAX_WIDTH).weight(1f), @@ -179,21 +219,17 @@ private fun LandscapeOnboardingMessage( } @Composable -private fun OnboardingIllustration(page: OnboardingPage, containerSize: Dp = 124.dp, imageSize: Dp = 76.dp) { - Box( - modifier = Modifier - .size(containerSize) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) - .border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape), - contentAlignment = Alignment.Center, - ) { - Image( - painter = painterResource(page.image), - contentDescription = stringResource(page.title), - modifier = Modifier.size(imageSize), - ) - } +private fun OnboardingIllustration(page: OnboardingPage, imageSize: Dp = 112.dp) { + Image( + painter = painterResource(page.image), + contentDescription = stringResource(page.title), + modifier = Modifier.size(imageSize), + colorFilter = if (page.tintIllustration) { + ColorFilter.tint(MaterialTheme.colorScheme.onPrimaryContainer) + } else { + null + }, + ) } @Composable @@ -202,13 +238,13 @@ private fun OnboardingText(page: OnboardingPage) { Text( stringResource(page.title), style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onBackground, + color = MaterialTheme.colorScheme.onPrimaryContainer, textAlign = TextAlign.Center, ) Spacer(Modifier.height(10.dp)) Text( stringResource(page.body), - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyLarge, ) @@ -232,11 +268,13 @@ private fun PolicyActions(onIntent: (OnboardingIntent) -> Unit) { label = stringResource(R.string.intro_policy_button), onClick = { onIntent(OnboardingIntent.OpenPrivacyPolicy) }, modifier = Modifier.weight(1f), + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ) SensorBoxSecondaryButton( label = stringResource(R.string.intro_terms_button), onClick = { onIntent(OnboardingIntent.OpenTermsOfUse) }, modifier = Modifier.weight(1f), + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ) } } @@ -247,6 +285,7 @@ private fun BatteryAction(onIntent: (OnboardingIntent) -> Unit) { label = stringResource(R.string.intro_battery_action), onClick = { onIntent(OnboardingIntent.RequestBatteryOptimizationExemption) }, modifier = Modifier.fillMaxWidth(), + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ) } @@ -259,12 +298,13 @@ private fun StorageAction(path: String?, onIntent: (OnboardingIntent) -> Unit) { ), onClick = { onIntent(OnboardingIntent.ChooseStorage) }, modifier = Modifier.fillMaxWidth(), + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ) if (path != null) { Spacer(Modifier.height(10.dp)) Text( stringResource(R.string.intro_storage_selected, path), - color = MaterialTheme.colorScheme.primary, + color = MaterialTheme.colorScheme.onPrimaryContainer, textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, ) @@ -284,15 +324,32 @@ private fun OnboardingControls( Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { OnboardingProgress(pageIndex) Spacer(Modifier.height(if (isLandscape) 12.dp else 20.dp)) - SensorBoxPrimaryButton( + OnboardingPrimaryButton( label = stringResource(if (isLastPage) R.string.intro_finish else R.string.next), onClick = { onIntent(onboardingForwardIntent(isLastPage)) }, - modifier = Modifier.widthIn(max = ONBOARDING_MESSAGE_MAX_WIDTH).fillMaxWidth(), enabled = !isLastPage || hasStorage, ) } } +@Composable +private fun OnboardingPrimaryButton(label: String, onClick: () -> Unit, enabled: Boolean) { + Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier.widthIn(min = 176.dp, max = 240.dp).height(52.dp), + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.onPrimaryContainer, + contentColor = MaterialTheme.colorScheme.primaryContainer, + disabledContainerColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.22f), + disabledContentColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.62f), + ), + ) { + Text(label, style = MaterialTheme.typography.labelLarge) + } +} + private fun onboardingForwardIntent(isLastPage: Boolean): OnboardingIntent = if (isLastPage) OnboardingIntent.CompleteOnboarding else OnboardingIntent.AdvanceOnboarding @@ -301,45 +358,48 @@ private data class OnboardingPage( @StringRes val body: Int, @DrawableRes val image: Int, val action: OnboardingAction = OnboardingAction.NONE, + val tintIllustration: Boolean = true, ) private enum class OnboardingAction { NONE, POLICIES, BATTERY, STORAGE } private val ONBOARDING_MAX_WIDTH = 960.dp private val ONBOARDING_MESSAGE_MAX_WIDTH = 520.dp +private const val ONBOARDING_TRANSITION_MILLIS = 320 private val ONBOARDING_PAGES = listOf( OnboardingPage( R.string.intro_welcome_title, R.string.intro_welcome_body, - R.drawable.ic_launcher_historic_round, + R.drawable.ic_sensorbox_logo, + tintIllustration = false, ), OnboardingPage( R.string.intro_incognito_title, R.string.intro_incognito_body, - R.drawable.ic_incognito, + R.drawable.ic_onboarding_private, ), OnboardingPage( R.string.intro_policy_title, R.string.intro_policy_body, - R.drawable.ic_bug, + R.drawable.ic_onboarding_policy, OnboardingAction.POLICIES, ), OnboardingPage( R.string.intro_lifecycle_title, R.string.intro_lifecycle_body, - R.drawable.ic_android_big, + R.drawable.ic_onboarding_paused, ), OnboardingPage( R.string.intro_battery_title, R.string.intro_battery_body, - R.drawable.ic_battery, + R.drawable.ic_onboarding_battery, OnboardingAction.BATTERY, ), OnboardingPage( R.string.intro_storage_title, R.string.intro_storage_body, - R.drawable.ic_folder, + R.drawable.ic_onboarding_storage, OnboardingAction.STORAGE, ), ) diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/PrivacyScreen.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/PrivacyScreen.kt index dddb890..7f69c6b 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/PrivacyScreen.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/PrivacyScreen.kt @@ -1,26 +1,19 @@ package com.motionapps.sensorbox.presentation.main -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import com.motionapps.sensorbox.R @Composable fun PrivacyScreen(onBack: () -> Unit, modifier: Modifier = Modifier) { - LazyColumn( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + SensorBoxBackScreen( + title = stringResource(R.string.privacy_health_data), + onBack = onBack, + modifier = modifier, ) { - item { SensorBoxTopAppBar(stringResource(R.string.privacy_health_data), onBack) } item { Text(stringResource(R.string.dialog_privacy_policy)) } item { Text(stringResource(R.string.privacy_access_title), style = MaterialTheme.typography.titleLarge) } item { 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 0f4bc74..ec4cacc 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 @@ -1,10 +1,9 @@ package com.motionapps.sensorbox.presentation.main +import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -12,13 +11,17 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -26,11 +29,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.motionapps.sensorbox.R import com.motionapps.sensorbox.domain.sensors.SensorDescriptor @@ -44,14 +44,19 @@ fun RecordScreen(state: RecordingState, onIntent: (RecordingIntent) -> Unit, mod } @Composable +@OptIn(ExperimentalFoundationApi::class) 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), + contentPadding = PaddingValues(start = 24.dp, top = 8.dp, end = 24.dp, bottom = 112.dp), ) { - item { RecordHeader(state) { onIntent(RecordingIntent.Navigate(MainRoute.SETTINGS)) } } - item { DeviceSectionHeader(stringResource(R.string.phone_sensors)) } - item { SensorSectionHeader(phoneSourceCount(state), state.sensors.size + 1) } + stickyHeader { + Surface(color = MaterialTheme.colorScheme.background) { + Column { + RecordHeader { onIntent(RecordingIntent.Navigate(MainRoute.SETTINGS)) } + Spacer(Modifier.height(18.dp)) + } + } + } item { GpsRow( selected = state.includesGps, @@ -59,17 +64,20 @@ private fun RecordContent(state: RecordingState, onIntent: (RecordingIntent) -> onInfo = { onIntent(RecordingIntent.OpenSensorDetails(null)) }, ) } + item { SourceDivider() } items(state.sensors, key = SensorDescriptor::type) { sensor -> - SensorRow( - sensor = sensor, - selected = sensor.type in state.selectedSensorIds, - onToggle = { onIntent(RecordingIntent.ToggleSensor(sensor.type)) }, - onInfo = { onIntent(RecordingIntent.OpenSensorDetails(sensor.type)) }, - ) + Column { + SensorRow( + sensor = sensor, + selected = sensor.type in state.selectedSensorIds, + onToggle = { onIntent(RecordingIntent.ToggleSensor(sensor.type)) }, + onInfo = { onIntent(RecordingIntent.OpenSensorDetails(sensor.type)) }, + ) + SourceDivider() + } } if (state.isWearConnected) { - item { DeviceSectionHeader(stringResource(R.string.wear_sensors)) } - item { SensorSectionHeader(wearSourceCount(state), state.wearSensors.size + 1) } + item { WearSectionHeader() } item { GpsRow( selected = state.wearIncludesGps, @@ -77,13 +85,17 @@ private fun RecordContent(state: RecordingState, onIntent: (RecordingIntent) -> onInfo = { onIntent(RecordingIntent.OpenSensorDetails(null)) }, ) } + item { SourceDivider() } items(state.wearSensors, key = { "wear_${it.type}" }) { sensor -> - SensorRow( - sensor = sensor, - selected = sensor.type in state.selectedWearSensorIds, - onToggle = { onIntent(RecordingIntent.ToggleWearSensor(sensor.type)) }, - onInfo = { onIntent(RecordingIntent.OpenSensorDetails(sensor.type)) }, - ) + Column { + SensorRow( + sensor = sensor, + selected = sensor.type in state.selectedWearSensorIds, + onToggle = { onIntent(RecordingIntent.ToggleWearSensor(sensor.type)) }, + onInfo = { onIntent(RecordingIntent.OpenSensorDetails(sensor.type)) }, + ) + SourceDivider() + } } } item { RecordingMessageText(state.message) } @@ -91,123 +103,94 @@ private fun RecordContent(state: RecordingState, onIntent: (RecordingIntent) -> } @Composable -private fun DeviceSectionHeader(label: String) { +private fun WearSectionHeader() { Text( - label, - modifier = Modifier.fillMaxWidth().padding(top = 14.dp), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.primary, + stringResource(R.string.wear_sensors), + modifier = Modifier.fillMaxWidth().padding(top = 28.dp, bottom = 8.dp), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @Composable private fun GpsRow(selected: Boolean, onToggle: () -> Unit, onInfo: () -> Unit) { - Surface( - modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.large, - border = BorderStroke(1.dp, sensorBorderColor(selected)), - ) { - Row(Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { - Image(painterResource(R.drawable.ic_gps), stringResource(R.string.gps), Modifier.size(54.dp)) - Spacer(Modifier.width(14.dp)) - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(stringResource(R.string.gps), style = MaterialTheme.typography.titleMedium) - Text(stringResource(R.string.device_location), color = MaterialTheme.colorScheme.onSurfaceVariant) - } - SensorInformationButton(stringResource(R.string.information_about_gps), onInfo) - Spacer(Modifier.width(12.dp)) - SensorSelectionIndicator(selected) - } - } + SourceRow( + title = stringResource(R.string.gps), + icon = R.drawable.ic_source_location, + selected = selected, + informationDescription = stringResource(R.string.information_about_gps), + onToggle = onToggle, + onInfo = onInfo, + ) } @Composable -private fun RecordHeader(state: RecordingState, onOptions: () -> Unit) { - val optionsDescription = stringResource(R.string.options) - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { - SensorBoxScreenHeader( - title = stringResource(R.string.pick_sensors), - subtitle = stringResource( - if (state.isWearConnected) R.string.phone_and_wear_ready else R.string.choose_measurement_signals, - ), - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(12.dp)) - Surface( - onClick = onOptions, - modifier = Modifier.size(48.dp).semantics { contentDescription = optionsDescription }, - shape = CircleShape, - color = MaterialTheme.colorScheme.surfaceVariant, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - ) { - Box(contentAlignment = Alignment.Center) { +private fun RecordHeader(onOptions: () -> Unit) { + SensorBoxTopAppBar( + title = stringResource(R.string.sources), + actions = { + IconButton(onClick = onOptions) { Icon( painterResource(R.drawable.ic_baseline_settings_24), - contentDescription = null, + contentDescription = stringResource(R.string.options), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - } - } -} - -@Composable -private fun SensorSectionHeader(selected: Int, available: Int) { - Row(Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 2.dp), horizontalArrangement = Arrangement.SpaceBetween) { - Text(stringResource(R.string.sensors), style = MaterialTheme.typography.titleLarge) - Text( - stringResource(R.string.selected_count, selected, available), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + }, + ) } @Composable private fun SensorRow(sensor: SensorDescriptor, selected: Boolean, onToggle: () -> Unit, onInfo: () -> Unit) { - Surface( - modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.large, - border = BorderStroke(1.dp, sensorBorderColor(selected)), - ) { - Row(Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { - SensorIdentity(sensor) - Spacer(Modifier.width(14.dp)) - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(sensor.name, style = MaterialTheme.typography.titleMedium) - Text(sensor.vendor, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - SensorInformationButton(stringResource(R.string.information_about_sensor, sensor.name), onInfo) - Spacer(Modifier.width(12.dp)) - SensorSelectionIndicator(selected) - } - } + SourceRow( + title = sensor.name, + icon = sensorIconResource(sensor.type), + selected = selected, + informationDescription = stringResource(R.string.information_about_sensor, sensor.name), + onToggle = onToggle, + onInfo = onInfo, + ) } @Composable -private fun SensorInformationButton(contentDescription: String, onClick: () -> Unit) { - Surface( - onClick = onClick, - modifier = Modifier.size(40.dp), - shape = CircleShape, - color = MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), +private fun SourceRow( + title: String, + @DrawableRes icon: Int, + selected: Boolean, + informationDescription: String, + onToggle: () -> Unit, + onInfo: () -> Unit, +) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Box(contentAlignment = Alignment.Center) { - Text( - stringResource(R.string.info_symbol), - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.semantics { this.contentDescription = contentDescription }, + Icon(painterResource(icon), null, Modifier.size(34.dp), tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(18.dp)) + Text( + title, + Modifier.weight(1f), + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + IconButton(onClick = onInfo) { + Icon( + painterResource(R.drawable.ic_info), + contentDescription = informationDescription, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), ) } + Spacer(Modifier.width(4.dp)) + Checkbox(checked = selected, onCheckedChange = { onToggle() }) } } @Composable -private fun sensorBorderColor(selected: Boolean) = - if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant +private fun SourceDivider() { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.72f)) +} @Composable private fun SensorSelectionActionBar( @@ -216,24 +199,27 @@ private fun SensorSelectionActionBar( 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(RecordingIntent.OpenMeasurementSetup) }, - modifier = modifier, - ) + Surface(modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.background) { + Box( + Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + SensorBoxPrimaryButton( + label = stringResource(R.string.continue_action), + onClick = { onIntent(RecordingIntent.OpenMeasurementSetup) }, + modifier = Modifier.widthIn(min = 176.dp, max = 240.dp), + enabled = sensorCount > 0, + ) + } + } } -private fun phoneSourceCount(state: RecordingState): Int = - state.selectedSensorIds.size + if (state.includesGps) 1 else 0 - -private fun wearSourceCount(state: RecordingState): Int = - state.selectedWearSensorIds.size + if (state.wearIncludesGps) 1 else 0 - -private fun selectedSourceCount(state: RecordingState): Int = phoneSourceCount(state) + wearSourceCount(state) + - (if (state.activityRecognition) 1 else 0) + (if (state.significantMotion) 1 else 0) +private fun selectedSourceCount(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) @Composable fun RecordingMessageText(message: RecordingMessage) { diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt index 7868d15..94b1c3f 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt @@ -234,7 +234,14 @@ class RecordingViewModel @Inject constructor( } private fun stopMeasurement() { - viewModelScope.launch { workflow.stop().showFailure() } + viewModelScope.launch { + val result = workflow.stop() + if (result.isSuccess) { + mutableEffects.send(RecordingEffect.Navigate(MainRoute.RECORD)) + } else { + result.showFailure() + } + } } private fun updatePreference(intent: AppPreferencesIntent) { diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorBoxApp.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorBoxApp.kt index 2b96ee7..acc61d2 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorBoxApp.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorBoxApp.kt @@ -136,6 +136,7 @@ private fun MainRoute.isRootDestination() = when (this) { MainRoute.SENSOR_PREVIEW, MainRoute.SETUP, MainRoute.SETTINGS, + MainRoute.DIAGNOSTICS, MainRoute.LICENSES, MainRoute.PRIVACY, -> false @@ -198,6 +199,15 @@ private fun RouteContent( ) } + MainRoute.DIAGNOSTICS -> FullScreen { modifier -> + DiagnosticsLogScreen( + state = settingsState, + onBack = onBack, + onRefresh = { onSettingsIntent(SettingsIntent.ViewDiagnostics) }, + modifier = modifier, + ) + } + MainRoute.PRIVACY -> FullScreen { modifier -> PrivacyScreen(onBack, modifier) } } } diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorBoxComponents.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorBoxComponents.kt index dcaf210..f5ea7dc 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorBoxComponents.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorBoxComponents.kt @@ -7,17 +7,23 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -28,9 +34,11 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.motionapps.sensorbox.R import com.motionapps.sensorbox.domain.sensors.SensorDescriptor @@ -45,35 +53,80 @@ fun SensorBoxScreenHeader(title: String, subtitle: String, modifier: Modifier = } @Composable -fun SensorBoxTopAppBar(title: String, onBack: () -> Unit, modifier: Modifier = Modifier) { +fun SensorBoxTopAppBar( + title: String, + modifier: Modifier = Modifier, + navigation: @Composable RowScope.() -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, +) { Row( modifier = modifier.fillMaxWidth().heightIn(min = 64.dp), verticalAlignment = Alignment.CenterVertically, ) { - IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) { - Icon( - painter = painterResource(R.drawable.ic_arrow_back_24), - contentDescription = stringResource(R.string.back), - modifier = Modifier.size(28.dp), - ) - } - Spacer(Modifier.width(12.dp)) + navigation() Text( text = title, modifier = Modifier.weight(1f), style = MaterialTheme.typography.headlineSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + ) + actions() + } +} + +@Composable +fun SensorBoxBackScreen( + title: String, + onBack: () -> Unit, + modifier: Modifier = Modifier, + bottomPadding: Dp = SCREEN_CONTENT_BOTTOM_PADDING, + itemSpacing: Dp = SCREEN_ITEM_SPACING, + content: LazyListScope.() -> Unit, +) { + Column(modifier.fillMaxSize()) { + SensorBoxTopAppBar( + title = title, + modifier = Modifier.padding( + start = SCREEN_NAVIGATION_EDGE_PADDING, + top = SCREEN_TOP_PADDING, + end = SCREEN_HORIZONTAL_PADDING, + ), + navigation = { + IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back_24), + contentDescription = stringResource(R.string.back), + modifier = Modifier.size(28.dp), + ) + } + Spacer(Modifier.width(12.dp)) + }, + ) + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues( + start = SCREEN_HORIZONTAL_PADDING, + top = SCREEN_CONTENT_TOP_PADDING, + end = SCREEN_HORIZONTAL_PADDING, + bottom = bottomPadding, + ), + verticalArrangement = Arrangement.spacedBy(itemSpacing), + content = content, ) } } @Composable -fun SensorBoxBackButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) { +fun SensorBoxBackButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + contentColor: Color = MaterialTheme.colorScheme.primary, +) { TextButton( onClick = onClick, modifier = modifier.heightIn(min = 48.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp, vertical = 8.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 14.dp, vertical = 8.dp), + colors = ButtonDefaults.textButtonColors(contentColor = contentColor), ) { Icon( painter = painterResource(R.drawable.ic_arrow_back_24), @@ -130,13 +183,18 @@ fun SensorBoxDangerButton(label: String, onClick: () -> Unit, modifier: Modifier } @Composable -fun SensorBoxSecondaryButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) { +fun SensorBoxSecondaryButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + contentColor: Color = MaterialTheme.colorScheme.onSurface, +) { OutlinedButton( onClick = onClick, modifier = modifier.height(48.dp), shape = MaterialTheme.shapes.medium, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurface), + border = BorderStroke(1.dp, contentColor.copy(alpha = 0.72f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = contentColor), ) { Text(label, style = MaterialTheme.typography.labelLarge) } @@ -154,7 +212,6 @@ fun SensorBoxBottomAction( Surface( modifier = modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surface, - shadowElevation = 8.dp, ) { Column(Modifier.padding(horizontal = 20.dp, vertical = 14.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { @@ -208,3 +265,25 @@ fun SettingSummary(title: String, description: String, trailing: @Composable () trailing() } } + +@Composable +fun SensorBoxSettingsSection(title: String) { + Text( + text = title, + modifier = Modifier.fillMaxWidth().padding(top = 20.dp, bottom = 4.dp), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) +} + +@Composable +fun SensorBoxSettingsDivider() { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.72f)) +} + +private val SCREEN_NAVIGATION_EDGE_PADDING = 4.dp +private val SCREEN_TOP_PADDING = 8.dp +private val SCREEN_HORIZONTAL_PADDING = 24.dp +private val SCREEN_CONTENT_TOP_PADDING = 8.dp +private val SCREEN_CONTENT_BOTTOM_PADDING = 24.dp +private val SCREEN_ITEM_SPACING = 10.dp 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 7ac1d7b..16b4da3 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 @@ -7,15 +7,16 @@ import android.hardware.Sensor import android.location.Location import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -51,63 +52,65 @@ fun SensorDetailsScreen( sensor != null -> sensor.name else -> stringResource(R.string.sensor_unavailable) } - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + val showPreview = (state.detailsSensorType == null || sensor != null) && canPreview + Box(modifier.fillMaxSize()) { + SensorDetailsList(state, sensor, title, showPreview, onBack) + if (showPreview) { + PreviewAction(onPreview, Modifier.align(Alignment.BottomCenter)) + } + } +} + +@Composable +private fun SensorDetailsList( + state: RecordingState, + sensor: SensorDescriptor?, + title: String, + showPreview: Boolean, + onBack: () -> Unit, +) { + SensorBoxBackScreen( + title = title, + onBack = onBack, + bottomPadding = if (showPreview) 104.dp else 24.dp, ) { - item { SensorBoxTopAppBar(title, onBack) } if (state.detailsSensorType == null) { item { GpsDetails(state) } } else if (sensor != null) { item { HardwareSensorDetails(sensor) } } - if ((state.detailsSensorType == null || sensor != null) && canPreview) { - item { - SensorBoxPrimaryButton( - label = stringResource(R.string.preview), - onClick = onPreview, - modifier = Modifier.fillMaxWidth(), - ) - } - } } } @Composable private fun HardwareSensorDetails(sensor: SensorDescriptor) { - SensorBoxPanel { - Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - val unit = sensorUnit(sensor.type) - val type = if (sensor.stringType.isBlank()) { - stringResource(R.string.detail_sensor_type_value, sensor.type) - } else { - sensor.stringType - } - DetailRow(stringResource(R.string.detail_name), sensor.name) - DetailRow(stringResource(R.string.detail_version), sensor.version.toString()) - DetailRow(stringResource(R.string.detail_vendor), sensor.vendor) - DetailRow(stringResource(R.string.detail_resolution, unit), sensor.resolution.toString()) - DetailRow(stringResource(R.string.detail_power), formatDecimal(sensor.power)) - DetailRow(stringResource(R.string.detail_maximum_range, unit), formatDecimal(sensor.maximumRange)) - DetailRow( - stringResource(R.string.detail_minimum_delay), - stringResource(R.string.detail_delay_value, sensor.minimumDelayMicros), - ) - DetailRow( - stringResource(R.string.detail_maximum_delay), - stringResource(R.string.detail_delay_value, sensor.maximumDelayMicros), - ) - DetailRow( - stringResource(R.string.detail_android_sensor_type), - type, - ) - DetailRow(stringResource(R.string.detail_reporting_mode), reportingModeLabel(sensor.reportingMode)) - DetailRow( - stringResource(R.string.detail_wakeup_sensor), - stringResource(if (sensor.isWakeUpSensor) R.string.yes else R.string.no), - ) + Column(Modifier.fillMaxWidth()) { + val unit = sensorUnit(sensor.type) + val type = if (sensor.stringType.isBlank()) { + stringResource(R.string.detail_sensor_type_value, sensor.type) + } else { + sensor.stringType } + ParameterRow(stringResource(R.string.detail_version), sensor.version.toString()) + ParameterRow(stringResource(R.string.detail_vendor), sensor.vendor) + ParameterRow(stringResource(R.string.detail_resolution, unit), sensor.resolution.toString()) + ParameterRow(stringResource(R.string.detail_power), formatDecimal(sensor.power)) + ParameterRow(stringResource(R.string.detail_maximum_range, unit), formatDecimal(sensor.maximumRange)) + ParameterRow( + stringResource(R.string.detail_minimum_delay), + stringResource(R.string.detail_delay_value, sensor.minimumDelayMicros), + ) + ParameterRow( + stringResource(R.string.detail_maximum_delay), + stringResource(R.string.detail_delay_value, sensor.maximumDelayMicros), + ) + ParameterRow(stringResource(R.string.detail_android_sensor_type), type) + ParameterRow(stringResource(R.string.detail_reporting_mode), reportingModeLabel(sensor.reportingMode)) + ParameterRow( + stringResource(R.string.detail_wakeup_sensor), + stringResource(if (sensor.isWakeUpSensor) R.string.yes else R.string.no), + showDivider = false, + ) } } @@ -125,9 +128,11 @@ private fun GpsDetails(state: RecordingState) { permissionRevision = permissionRevision, ) val unavailableValue = stringResource(if (details.hasPermission) R.string.waiting else R.string.unavailable) - SensorBoxPanel { - Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - GpsPermission(details.hasPermission) { + Column(Modifier.fillMaxWidth()) { + GpsPermissionStatus(details.hasPermission) + GpsDetailRows(details, state, unavailableValue) + if (!details.hasPermission) { + GpsPermissionAction { permissionRequest.launch( arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, @@ -135,28 +140,27 @@ private fun GpsDetails(state: RecordingState) { ), ) } - GpsDetailRows(details, state, unavailableValue) } } } @Composable private fun GpsDetailRows(details: GpsDetailsState, state: RecordingState, unavailableValue: String) { - DetailRow( + ParameterRow( stringResource(R.string.detail_latitude), details.location?.latitude?.toString() ?: unavailableValue, ) - DetailRow( + ParameterRow( stringResource(R.string.detail_longitude), details.location?.longitude?.toString() ?: unavailableValue, ) - DetailRow(stringResource(R.string.detail_altitude), localizedValue(details.location?.altitude, unavailableValue)) - DetailRow(stringResource(R.string.detail_accuracy), localizedValue(details.location?.accuracy, unavailableValue)) - DetailRow(stringResource(R.string.detail_speed), localizedValue(details.location?.speed, unavailableValue)) - DetailRow(stringResource(R.string.detail_bearing), localizedValue(details.location?.bearing, unavailableValue)) - DetailRow(stringResource(R.string.detail_provider), details.location?.provider ?: unavailableValue) - DetailRow(stringResource(R.string.detail_available), locationAvailabilityLabel(details.isAvailable)) - DetailRow( + ParameterRow(stringResource(R.string.detail_altitude), localizedValue(details.location?.altitude, unavailableValue)) + ParameterRow(stringResource(R.string.detail_accuracy), localizedValue(details.location?.accuracy, unavailableValue)) + ParameterRow(stringResource(R.string.detail_speed), localizedValue(details.location?.speed, unavailableValue)) + ParameterRow(stringResource(R.string.detail_bearing), localizedValue(details.location?.bearing, unavailableValue)) + ParameterRow(stringResource(R.string.detail_provider), details.location?.provider ?: unavailableValue) + ParameterRow(stringResource(R.string.detail_available), locationAvailabilityLabel(details.isAvailable)) + ParameterRow( stringResource(R.string.detail_update_interval), pluralStringResource( R.plurals.seconds_count, @@ -164,13 +168,14 @@ private fun GpsDetailRows(details: GpsDetailsState, state: RecordingState, unava state.preferences.recording.gpsIntervalSeconds, ), ) - DetailRow( + ParameterRow( stringResource(R.string.detail_minimum_distance), pluralStringResource( R.plurals.meters_count, state.preferences.recording.gpsMinDistanceMeters, state.preferences.recording.gpsMinDistanceMeters, ), + showDivider = false, ) } @@ -179,14 +184,24 @@ private fun localizedValue(value: Number?, unavailableValue: String): String = @Composable internal fun GpsPermission(hasPermission: Boolean, onRequest: () -> Unit) { - DetailRow( + GpsPermissionStatus(hasPermission) + if (!hasPermission) GpsPermissionAction(onRequest) +} + +@Composable +private fun GpsPermissionStatus(hasPermission: Boolean) { + ParameterRow( stringResource(R.string.location_permission), stringResource(if (hasPermission) R.string.granted else R.string.required), ) - if (hasPermission) return +} + +@Composable +private fun GpsPermissionAction(onRequest: () -> Unit) { Text( stringResource(R.string.location_permission_explanation), color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 24.dp, bottom = 12.dp), ) SensorBoxSecondaryButton( label = stringResource(R.string.grant_location_permission), @@ -195,6 +210,30 @@ internal fun GpsPermission(hasPermission: Boolean, onRequest: () -> Unit) { ) } +@Composable +private fun ParameterRow(label: String, value: String, showDivider: Boolean = true) { + DetailRow(label, value, Modifier.padding(vertical = 15.dp)) + if (showDivider) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.72f)) + } +} + +@Composable +private fun PreviewAction(onPreview: () -> Unit, modifier: Modifier = Modifier) { + Surface(modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.background) { + Box( + Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + SensorBoxPrimaryButton( + label = stringResource(R.string.preview), + onClick = onPreview, + modifier = Modifier.widthIn(min = 176.dp, max = 240.dp), + ) + } + } +} + @Composable internal fun rememberGpsDetails( intervalSeconds: Int, @@ -228,8 +267,8 @@ internal fun rememberGpsDetails( } @Composable -internal fun DetailRow(label: String, value: String) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { +internal fun DetailRow(label: String, value: String, modifier: Modifier = Modifier) { + Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { Text(label, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant) Text(value, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurface) } 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 7908633..8129d0c 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 @@ -13,15 +13,13 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -54,16 +52,11 @@ import kotlin.math.max 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) - sensor != null -> stringResource(R.string.sensor_preview_title, sensor.name) + state.detailsSensorType == null -> stringResource(R.string.gps) + sensor != null -> sensor.name else -> stringResource(R.string.sensor_unavailable) } - LazyColumn( - modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item { SensorBoxTopAppBar(title, onBack) } + SensorBoxBackScreen(title = title, onBack = onBack, modifier = modifier) { if (state.detailsSensorType == null) { item { GpsPreview(state) } } else if (sensor != null) { @@ -77,22 +70,17 @@ private fun HardwareSensorPreview(sensor: SensorDescriptor) { val preview = rememberHardwareSensorPreview(sensor.type) val latestValues = preview.samples.lastOrNull()?.values val unit = sensorUnit(sensor.type) - SensorBoxPanel { - Column( - Modifier.fillMaxWidth().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - if (!preview.isAvailable) { - Text(stringResource(R.string.sensor_activation_failed), color = MaterialTheme.colorScheme.error) - return@Column - } - if (sensor.type == Sensor.TYPE_STEP_COUNTER) { - StepCounterPreview(latestValues?.firstOrNull()) - } else { - SensorValues(latestValues, unit) - Text(stringResource(R.string.live_chart), style = MaterialTheme.typography.titleMedium) - LiveSensorChart(preview.samples, unit) - } + Column(Modifier.fillMaxWidth()) { + if (!preview.isAvailable) { + Text(stringResource(R.string.sensor_activation_failed), color = MaterialTheme.colorScheme.error) + return@Column + } + if (sensor.type == Sensor.TYPE_STEP_COUNTER) { + StepCounterPreview(latestValues?.firstOrNull()) + } else { + SensorValues(latestValues, unit) + PreviewSectionTitle(stringResource(R.string.live_chart)) + LiveSensorChart(preview.samples, unit) } } } @@ -120,13 +108,32 @@ private fun SensorValues(values: FloatArray?, unit: String) { return } values.forEachIndexed { index, value -> - DetailRow( + PreviewValueRow( label = stringResource(R.string.sensor_value_with_unit, axisLabel(index, values.size), unit), value = formatDecimal(value), + showDivider = index != values.lastIndex, ) } } +@Composable +private fun PreviewValueRow(label: String, value: String, showDivider: Boolean = true) { + DetailRow(label, value, Modifier.padding(vertical = 14.dp)) + if (showDivider) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.72f)) + } +} + +@Composable +private fun PreviewSectionTitle(title: String) { + Text( + title, + modifier = Modifier.fillMaxWidth().padding(top = 28.dp, bottom = 12.dp), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) +} + @Composable private fun LiveSensorChart(samples: List, unit: String) { val axisCount = samples.maxOfOrNull { it.values.size }?.coerceAtMost(MAX_CHART_AXES) ?: 0 @@ -336,24 +343,38 @@ private fun GpsPreview(state: RecordingState) { @Composable private fun GpsPreviewPanel(details: GpsDetailsState, unavailableValue: String, onRequestPermission: () -> Unit) { - SensorBoxPanel { - Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - GpsPermission(details.hasPermission, onRequestPermission) - DetailRow( - stringResource(R.string.detail_latitude), - details.location?.latitude?.toString() ?: unavailableValue, - ) - DetailRow( - stringResource(R.string.detail_longitude), - details.location?.longitude?.toString() ?: unavailableValue, - ) - DetailRow( - stringResource(R.string.detail_altitude), - details.location?.altitude?.let(::formatDecimal) ?: unavailableValue, + Column(Modifier.fillMaxWidth()) { + PreviewValueRow( + stringResource(R.string.location_permission), + stringResource(if (details.hasPermission) R.string.granted else R.string.required), + ) + PreviewValueRow( + stringResource(R.string.detail_latitude), + details.location?.latitude?.toString() ?: unavailableValue, + ) + PreviewValueRow( + stringResource(R.string.detail_longitude), + details.location?.longitude?.toString() ?: unavailableValue, + ) + PreviewValueRow( + stringResource(R.string.detail_altitude), + details.location?.altitude?.let(::formatDecimal) ?: unavailableValue, + ) + PreviewValueRow( + stringResource(R.string.detail_bearing), + details.location?.bearing?.let(::formatDecimal) ?: unavailableValue, + showDivider = false, + ) + if (!details.hasPermission) { + Text( + stringResource(R.string.location_permission_explanation), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 24.dp, bottom = 12.dp), ) - DetailRow( - stringResource(R.string.detail_bearing), - details.location?.bearing?.let(::formatDecimal) ?: unavailableValue, + SensorBoxSecondaryButton( + label = stringResource(R.string.grant_location_permission), + onClick = onRequestPermission, + modifier = Modifier.fillMaxWidth(), ) } } @@ -410,7 +431,7 @@ private data class ChartPlot(val left: Float, val top: Float, val right: Float, val height: Float = bottom - top } -private val CHART_HEIGHT = 220.dp +private val CHART_HEIGHT = 260.dp private val CHART_LEFT_MARGIN = 56.dp private val CHART_RIGHT_MARGIN = 10.dp private val CHART_TOP_MARGIN = 10.dp diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsContract.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsContract.kt index ab6521a..97972aa 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsContract.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsContract.kt @@ -2,10 +2,12 @@ package com.motionapps.sensorbox.presentation.main import com.motionapps.sensorbox.core.error.AppErrorCode import com.motionapps.sensorbox.core.preferences.AppPreferences +import com.motionapps.sensorbox.core.preferences.AppThemeMode data class SettingsState( val preferences: AppPreferences = AppPreferences(), val diagnosticsText: String? = null, + val diagnosticsLoaded: Boolean = false, val errorCode: AppErrorCode? = null, ) @@ -16,6 +18,8 @@ sealed interface SettingsIntent { data class SetKeepScreenAwake(val enabled: Boolean) : SettingsIntent data class SetGpsInterval(val seconds: Int) : SettingsIntent data class SetGpsDistance(val meters: Int) : SettingsIntent + data class SetThemeMode(val mode: AppThemeMode) : SettingsIntent + data class SetDynamicColors(val enabled: Boolean) : SettingsIntent data object RequestBatteryOptimizationExemption : SettingsIntent data object ShareDiagnosticsText : SettingsIntent data object ShareDiagnosticsFile : SettingsIntent 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 d0bbd5a..95787b5 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 @@ -3,6 +3,7 @@ package com.motionapps.sensorbox.presentation.main import android.content.Context import android.os.PowerManager import android.widget.NumberPicker +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,17 +11,13 @@ 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 +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Switch @@ -29,6 +26,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -37,7 +35,9 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -46,6 +46,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import com.motionapps.sensorbox.R +import com.motionapps.sensorbox.core.preferences.AppThemeMode @Composable fun SettingsScreen( @@ -55,14 +56,23 @@ fun SettingsScreen( onBack: () -> Unit = { onIntent(SettingsIntent.Navigate(MainRoute.RECORD)) }, ) { val isBatteryOptimizationExempt = rememberBatteryOptimizationExemption() - LazyColumn( + LaunchedEffect(Unit) { onIntent(SettingsIntent.ViewDiagnostics) } + SensorBoxBackScreen( + title = stringResource(R.string.measurement_settings), + onBack = onBack, modifier = modifier, - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + itemSpacing = 0.dp, ) { + item { SensorBoxSettingsSection(stringResource(R.string.settings_appearance_category)) } + item { ThemeModeSetting(state.preferences.display.themeMode, onIntent) } item { - SensorBoxTopAppBar(stringResource(R.string.measurement_settings), onBack) + BooleanSetting( + title = stringResource(R.string.dynamic_colors), + description = stringResource(R.string.dynamic_colors_description), + checked = state.preferences.display.dynamicColors, + ) { onIntent(SettingsIntent.SetDynamicColors(it)) } } + item { SensorBoxSettingsSection(stringResource(R.string.settings_recording_category)) } item { SamplingSetting(state.preferences.recording.sensorSamplingPeriod) { index -> onIntent(SettingsIntent.SetSamplingPeriod(index)) @@ -72,73 +82,58 @@ fun SettingsScreen( item { BatteryOptimizationSetting(isBatteryOptimizationExempt, onIntent) } item { CpuWakeLockSetting(state, onIntent) } item { ScreenAwakeSetting(state, onIntent) } + item { SensorBoxSettingsSection(stringResource(R.string.settings_location_category)) } item { GpsSettings(state, onIntent) } + item { SensorBoxSettingsSection(stringResource(R.string.diagnostics_title)) } item { DiagnosticsSetting(state, onIntent) } + item { SensorBoxSettingsSection(stringResource(R.string.settings_app_category)) } item { AboutSetting(onIntent) } } } +@Composable +private fun ThemeModeSetting(selected: AppThemeMode, onIntent: (SettingsIntent) -> Unit) { + SettingsChoiceSetting( + title = stringResource(R.string.theme_mode), + description = stringResource(R.string.theme_mode_description), + options = listOf( + AppThemeMode.AUTOMATIC to stringResource(R.string.theme_automatic), + AppThemeMode.LIGHT to stringResource(R.string.theme_light), + AppThemeMode.DARK to stringResource(R.string.theme_dark), + ), + selected = selected, + ) { onIntent(SettingsIntent.SetThemeMode(it)) } +} + @Composable 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) - Text( - 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(SettingsIntent.ShareDiagnosticsText) }, - modifier = Modifier.fillMaxWidth(), - ) - SensorBoxSecondaryButton( - label = stringResource(R.string.diagnostics_share_file), - 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 (!state.diagnosticsLoaded) { + Text( + stringResource(R.string.diagnostics_loading), + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + } else if (state.diagnosticsText.isNullOrBlank()) { + Text( + stringResource(R.string.diagnostics_empty), + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + SettingsActionRow( + title = stringResource(R.string.diagnostics_view), + description = stringResource(R.string.diagnostics_view_summary), + ) { onIntent(SettingsIntent.Navigate(MainRoute.DIAGNOSTICS)) } + SettingsActionRow( + title = stringResource(R.string.diagnostics_share), + description = stringResource(R.string.diagnostics_share_summary), + ) { onIntent(SettingsIntent.ShareDiagnosticsFile) } + SettingsActionRow( + title = stringResource(R.string.diagnostics_clear), + description = stringResource(R.string.diagnostics_clear_summary), + showDivider = false, + ) { confirmClear = true } } if (confirmClear) { @@ -167,7 +162,7 @@ private fun DiagnosticsSetting(state: SettingsState, onIntent: (SettingsIntent) @Composable private fun GpsSettings(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column { NumberPickerSetting( stringResource(R.string.gps_interval), state.preferences.recording.gpsIntervalSeconds, @@ -204,17 +199,16 @@ private fun NumberPickerSetting( ) { var showPicker by rememberSaveable { mutableStateOf(false) } - SensorBoxPanel { - Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { - Text(title, style = MaterialTheme.typography.titleMedium) - Text(valueLabel, color = MaterialTheme.colorScheme.primary) - } - OutlinedButton(onClick = { showPicker = true }) { - Text(value.toString()) - } + Row(Modifier.fillMaxWidth().padding(vertical = 16.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(valueLabel, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + OutlinedButton(onClick = { showPicker = true }) { + Text(value.toString()) } } + SensorBoxSettingsDivider() if (showPicker) { var selectedValue by remember(value, minimum, maximum) { @@ -289,44 +283,35 @@ private fun ScreenAwakeSetting(state: SettingsState, onIntent: (SettingsIntent) @Composable 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) - Text( - stringResource( - if (isExempt) R.string.battery_optimization_exempt else R.string.battery_optimization_restricted, - ), - color = if (isExempt) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + Column(Modifier.fillMaxWidth().padding(vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.battery_optimization), style = MaterialTheme.typography.titleMedium) + Text( + stringResource( + if (isExempt) R.string.battery_optimization_exempt else R.string.battery_optimization_restricted, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (!isExempt) { + SensorBoxSecondaryButton( + label = stringResource(R.string.exclude_from_battery_saving), + onClick = { onIntent(SettingsIntent.RequestBatteryOptimizationExemption) }, + modifier = Modifier.fillMaxWidth(), ) - if (!isExempt) { - SensorBoxSecondaryButton( - label = stringResource(R.string.exclude_from_battery_saving), - onClick = { onIntent(SettingsIntent.RequestBatteryOptimizationExemption) }, - modifier = Modifier.fillMaxWidth(), - ) - } } } + SensorBoxSettingsDivider() } @Composable private fun AboutSetting(onIntent: (SettingsIntent) -> Unit) { var showAboutDialog by rememberSaveable { mutableStateOf(false) } - SensorBoxPanel { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text(stringResource(R.string.about_title), style = MaterialTheme.typography.titleMedium) - Text( - stringResource(R.string.about_summary), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - SensorBoxSecondaryButton( - label = stringResource(R.string.menu_about), - onClick = { showAboutDialog = true }, - modifier = Modifier.fillMaxWidth(), - ) - } - } + SettingsActionRow( + title = stringResource(R.string.menu_about), + description = stringResource(R.string.about_summary), + showDivider = false, + showChevron = true, + ) { showAboutDialog = true } if (showAboutDialog) { AboutDialog( @@ -364,31 +349,98 @@ private fun Context.isBatteryOptimizationExempt(): Boolean = @Composable 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) - Text( - stringResource(R.string.sensor_sampling_description), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) { - listOf( - R.string.sampling_fastest, - R.string.sampling_game, - R.string.sampling_ui, - R.string.sampling_normal, - ).forEachIndexed { index, label -> - SamplingChip(stringResource(label), selected == index) { - onSamplingPeriod(index) - } + SettingsChoiceSetting( + title = stringResource(R.string.sensor_sampling), + description = stringResource(R.string.sensor_sampling_description), + options = listOf( + 0 to stringResource(R.string.sampling_fastest), + 1 to stringResource(R.string.sampling_game), + 2 to stringResource(R.string.sampling_ui), + 3 to stringResource(R.string.sampling_normal), + ), + selected = selected, + onSelected = onSamplingPeriod, + ) +} + +@Composable +private fun SettingsChoiceSetting( + title: String, + description: String, + options: List>, + selected: T, + onSelected: (T) -> Unit, +) { + Column( + Modifier.fillMaxWidth().padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(description, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) { + options.forEach { (value, label) -> + SettingsChoiceChip(label, selected == value) { + onSelected(value) } } } } + SensorBoxSettingsDivider() } @Composable -private fun SamplingChip(label: String, selected: Boolean, onClick: () -> Unit) { +fun BooleanSetting(title: String, description: String, checked: Boolean, onChecked: (Boolean) -> Unit) { + Row( + Modifier.fillMaxWidth().padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(description, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.width(14.dp)) + Switch( + checked = checked, + onCheckedChange = onChecked, + colors = SwitchDefaults.colors(checkedTrackColor = MaterialTheme.colorScheme.primary), + ) + } + SensorBoxSettingsDivider() +} + +@Composable +private fun SettingsActionRow( + title: String, + description: String, + showDivider: Boolean = true, + showChevron: Boolean = false, + onClick: () -> Unit, +) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(description, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (showChevron) { + Spacer(Modifier.width(14.dp)) + Icon( + painter = painterResource(R.drawable.ic_expand_more_24), + contentDescription = null, + modifier = Modifier.size(24.dp).rotate(-90f), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (showDivider) SensorBoxSettingsDivider() +} + +@Composable +private fun SettingsChoiceChip(label: String, selected: Boolean, onClick: () -> Unit) { FilterChip( selected = selected, onClick = onClick, @@ -400,32 +452,18 @@ private fun SamplingChip(label: String, selected: Boolean, onClick: () -> Unit) ) } -@Composable -fun BooleanSetting(title: String, description: String, checked: Boolean, onChecked: (Boolean) -> Unit) { - SensorBoxPanel { - SettingSummary(title, description) { - Switch( - checked = checked, - onCheckedChange = onChecked, - colors = SwitchDefaults.colors(checkedTrackColor = MaterialTheme.colorScheme.primary), - ) - } - } -} - @Composable fun StepSetting(title: String, value: Int, valueLabel: String, minimum: Int, maximum: Int, onValue: (Int) -> Unit) { - SensorBoxPanel { - Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { - Text(title, style = MaterialTheme.typography.titleMedium) - Text(valueLabel, color = MaterialTheme.colorScheme.primary) - } - StepButton(stringResource(R.string.decrement)) { onValue((value - 1).coerceAtLeast(minimum)) } - Spacer(Modifier.width(8.dp)) - StepButton(stringResource(R.string.increment)) { onValue((value + 1).coerceAtMost(maximum)) } + Row(Modifier.fillMaxWidth().padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(valueLabel, color = MaterialTheme.colorScheme.onSurfaceVariant) } + StepButton(stringResource(R.string.decrement)) { onValue((value - 1).coerceAtLeast(minimum)) } + Spacer(Modifier.width(8.dp)) + StepButton(stringResource(R.string.increment)) { onValue((value + 1).coerceAtMost(maximum)) } } + SensorBoxSettingsDivider() } @Composable diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsViewModel.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsViewModel.kt index c328990..7aa9425 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsViewModel.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsViewModel.kt @@ -54,9 +54,9 @@ class SettingsViewModel @Inject constructor( SettingsIntent.RequestBatteryOptimizationExemption -> mutableEffects.trySend(SettingsEffect.RequestBatteryOptimizationExemption) - SettingsIntent.ShareDiagnosticsText -> mutableEffects.trySend(SettingsEffect.ShareDiagnosticsText) + SettingsIntent.ShareDiagnosticsText -> shareDiagnosticsText() - SettingsIntent.ShareDiagnosticsFile -> mutableEffects.trySend(SettingsEffect.ShareDiagnosticsFile) + SettingsIntent.ShareDiagnosticsFile -> shareDiagnosticsFile() SettingsIntent.ViewDiagnostics -> viewDiagnostics() @@ -74,6 +74,8 @@ class SettingsViewModel @Inject constructor( is SettingsIntent.SetKeepScreenAwake, is SettingsIntent.SetGpsInterval, is SettingsIntent.SetGpsDistance, + is SettingsIntent.SetThemeMode, + is SettingsIntent.SetDynamicColors, -> Unit } } @@ -85,6 +87,8 @@ class SettingsViewModel @Inject constructor( is SettingsIntent.SetKeepScreenAwake -> AppPreferencesIntent.SetKeepPhoneDisplayOn(enabled) is SettingsIntent.SetGpsInterval -> AppPreferencesIntent.SetGpsInterval(seconds) is SettingsIntent.SetGpsDistance -> AppPreferencesIntent.SetGpsMinDistance(meters) + is SettingsIntent.SetThemeMode -> AppPreferencesIntent.SetThemeMode(mode) + is SettingsIntent.SetDynamicColors -> AppPreferencesIntent.SetDynamicColors(enabled) else -> null } @@ -101,6 +105,7 @@ class SettingsViewModel @Inject constructor( when (val result = diagnosticsStore.readText()) { is AppResult.Success -> mutableState.value = state.value.copy( diagnosticsText = result.value, + diagnosticsLoaded = true, errorCode = null, ) @@ -109,6 +114,31 @@ class SettingsViewModel @Inject constructor( } } + private fun shareDiagnosticsText() { + withDiagnostics { mutableEffects.send(SettingsEffect.ShareDiagnosticsText) } + } + + private fun shareDiagnosticsFile() { + withDiagnostics { mutableEffects.send(SettingsEffect.ShareDiagnosticsFile) } + } + + private fun withDiagnostics(action: suspend () -> Unit) { + viewModelScope.launch(ioDispatcher) { + when (val result = diagnosticsStore.readText()) { + is AppResult.Success -> { + mutableState.value = state.value.copy( + diagnosticsText = result.value, + diagnosticsLoaded = true, + errorCode = null, + ) + if (result.value.isNotBlank()) action() + } + + is AppResult.Failure -> fail(result.error.code) + } + } + } + private fun copyDiagnostics() { viewModelScope.launch(ioDispatcher) { when (val result = diagnosticsStore.readText()) { @@ -122,7 +152,11 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch(ioDispatcher) { when (val result = diagnosticsStore.clear()) { is AppResult.Success -> { - mutableState.value = state.value.copy(diagnosticsText = null, errorCode = null) + mutableState.value = state.value.copy( + diagnosticsText = "", + diagnosticsLoaded = true, + errorCode = null, + ) mutableEffects.send(SettingsEffect.DiagnosticsCleared) } diff --git a/app/src/main/java/com/motionapps/sensorbox/ui/theme/SensorBoxTheme.kt b/app/src/main/java/com/motionapps/sensorbox/ui/theme/SensorBoxTheme.kt index 9fce590..85467b3 100644 --- a/app/src/main/java/com/motionapps/sensorbox/ui/theme/SensorBoxTheme.kt +++ b/app/src/main/java/com/motionapps/sensorbox/ui/theme/SensorBoxTheme.kt @@ -1,61 +1,72 @@ package com.motionapps.sensorbox.ui.theme +import android.app.Activity +import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Shapes import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.view.WindowCompat +import com.motionapps.sensorbox.core.preferences.AppThemeMode val SensorBoxRecording = Color(0xFFFF6B72) private val DarkColors = darkColorScheme( - primary = Color(0xFF9AAEFF), - onPrimary = Color(0xFF11172A), - primaryContainer = Color(0xFF222C4A), - onPrimaryContainer = Color(0xFFDDE4FF), - secondary = Color(0xFFBAC4D8), - onSecondary = Color(0xFF202A38), - secondaryContainer = Color(0xFF28313D), - onSecondaryContainer = Color(0xFFDEE6F2), - tertiary = Color(0xFFAEB9CA), - background = Color(0xFF0B0D10), - onBackground = Color(0xFFF1F3F6), - surface = Color(0xFF111419), - onSurface = Color(0xFFF1F3F6), - surfaceVariant = Color(0xFF181C22), - onSurfaceVariant = Color(0xFFAAB2BF), - outline = Color(0xFF4A5360), - outlineVariant = Color(0xFF292F38), + primary = Color.White, + onPrimary = Color.Black, + primaryContainer = Color.Black, + onPrimaryContainer = Color.White, + secondary = Color(0xFFD0D0D0), + onSecondary = Color.Black, + secondaryContainer = Color(0xFF292929), + onSecondaryContainer = Color.White, + tertiary = Color(0xFFBDBDBD), + background = Color.Black, + onBackground = Color.White, + surface = Color(0xFF101010), + onSurface = Color.White, + surfaceVariant = Color(0xFF1C1C1C), + onSurfaceVariant = Color(0xFFC7C7C7), + outline = Color(0xFF8A8A8A), + outlineVariant = Color(0xFF363636), error = SensorBoxRecording, errorContainer = Color(0xFF4D2025), onErrorContainer = Color(0xFFFFDADC), ) private val LightColors = lightColorScheme( - primary = Color(0xFF445DA8), + primary = Color.Black, onPrimary = Color.White, - primaryContainer = Color(0xFFDDE4FF), - onPrimaryContainer = Color(0xFF17234A), - secondary = Color(0xFF566174), - secondaryContainer = Color(0xFFDCE4F2), - onSecondaryContainer = Color(0xFF182331), - tertiary = Color(0xFF596475), - background = Color(0xFFF7F8FA), - onBackground = Color(0xFF1A1D22), + primaryContainer = Color.Black, + onPrimaryContainer = Color.White, + secondary = Color(0xFF303030), + secondaryContainer = Color(0xFFE8E8E8), + onSecondaryContainer = Color.Black, + tertiary = Color(0xFF4A4A4A), + background = Color.White, + onBackground = Color.Black, surface = Color.White, - onSurface = Color(0xFF1A1D22), - surfaceVariant = Color(0xFFEEF1F5), - onSurfaceVariant = Color(0xFF59616D), - outline = Color(0xFF747D8A), - outlineVariant = Color(0xFFD9DEE6), + onSurface = Color.Black, + surfaceVariant = Color(0xFFF1F1F1), + onSurfaceVariant = Color(0xFF555555), + outline = Color(0xFF707070), + outlineVariant = Color(0xFFD8D8D8), error = Color(0xFFBA1A1A), errorContainer = Color(0xFFFFDAD6), onErrorContainer = Color(0xFF410002), @@ -79,9 +90,41 @@ private val SensorBoxShapes = Shapes( ) @Composable -fun SensorBoxTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { +fun SensorBoxTheme( + themeMode: AppThemeMode = AppThemeMode.AUTOMATIC, + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val systemDarkTheme = isSystemInDarkTheme() + val darkTheme = when (themeMode) { + AppThemeMode.AUTOMATIC -> systemDarkTheme + AppThemeMode.LIGHT -> false + AppThemeMode.DARK -> true + } + val colors = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColors + + else -> LightColors + } + val view = LocalView.current + val useDarkSystemBarIcons = colors.background.luminance() > 0.5f + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as? Activity)?.window ?: return@SideEffect + window.decorView.setBackgroundColor(colors.background.toArgb()) + WindowCompat.getInsetsController(window, view).apply { + isAppearanceLightStatusBars = useDarkSystemBarIcons + isAppearanceLightNavigationBars = useDarkSystemBarIcons + } + } + } MaterialTheme( - colorScheme = if (darkTheme) DarkColors else LightColors, + colorScheme = colors, typography = SensorBoxTypography, shapes = SensorBoxShapes, content = content, diff --git a/app/src/main/res/drawable/ic_acceleration_icon.xml b/app/src/main/res/drawable/ic_acceleration_icon.xml index 2dfffb3..398c0df 100644 --- a/app/src/main/res/drawable/ic_acceleration_icon.xml +++ b/app/src/main/res/drawable/ic_acceleration_icon.xml @@ -3,9 +3,6 @@ android:height="46dp" android:viewportWidth="46" android:viewportHeight="46"> - diff --git a/app/src/main/res/drawable/ic_android_big.xml b/app/src/main/res/drawable/ic_android_big.xml deleted file mode 100644 index e3b805b..0000000 --- a/app/src/main/res/drawable/ic_android_big.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_battery.xml b/app/src/main/res/drawable/ic_battery.xml deleted file mode 100644 index ff90961..0000000 --- a/app/src/main/res/drawable/ic_battery.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_bug.xml b/app/src/main/res/drawable/ic_bug.xml deleted file mode 100644 index 8949018..0000000 --- a/app/src/main/res/drawable/ic_bug.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_folder.xml b/app/src/main/res/drawable/ic_folder.xml deleted file mode 100644 index 1f0cf6f..0000000 --- a/app/src/main/res/drawable/ic_folder.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/ic_gravity_icon.xml b/app/src/main/res/drawable/ic_gravity_icon.xml index 1940e49..818b13c 100644 --- a/app/src/main/res/drawable/ic_gravity_icon.xml +++ b/app/src/main/res/drawable/ic_gravity_icon.xml @@ -3,9 +3,6 @@ android:height="46dp" android:viewportWidth="46" android:viewportHeight="46"> - diff --git a/app/src/main/res/drawable/ic_gyroscope_icon.xml b/app/src/main/res/drawable/ic_gyroscope_icon.xml index 6654c32..ad9068c 100644 --- a/app/src/main/res/drawable/ic_gyroscope_icon.xml +++ b/app/src/main/res/drawable/ic_gyroscope_icon.xml @@ -3,9 +3,6 @@ android:height="46dp" android:viewportWidth="46" android:viewportHeight="46"> - - - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_white_round.xml b/app/src/main/res/drawable/ic_launcher_white_round.xml deleted file mode 100644 index 55abfad..0000000 --- a/app/src/main/res/drawable/ic_launcher_white_round.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_white_square.xml b/app/src/main/res/drawable/ic_launcher_white_square.xml deleted file mode 100644 index 045d088..0000000 --- a/app/src/main/res/drawable/ic_launcher_white_square.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/ic_light.xml b/app/src/main/res/drawable/ic_light.xml index f97df7a..a74a3d3 100644 --- a/app/src/main/res/drawable/ic_light.xml +++ b/app/src/main/res/drawable/ic_light.xml @@ -3,9 +3,6 @@ android:height="46dp" android:viewportWidth="46" android:viewportHeight="46"> - diff --git a/app/src/main/res/drawable/ic_linear_acceleration_icon.xml b/app/src/main/res/drawable/ic_linear_acceleration_icon.xml index 85e233d..7cd545b 100644 --- a/app/src/main/res/drawable/ic_linear_acceleration_icon.xml +++ b/app/src/main/res/drawable/ic_linear_acceleration_icon.xml @@ -3,9 +3,6 @@ android:height="46dp" android:viewportWidth="46" android:viewportHeight="46"> - diff --git a/app/src/main/res/drawable/ic_magnet.xml b/app/src/main/res/drawable/ic_magnet.xml index 7cd63fd..002007c 100644 --- a/app/src/main/res/drawable/ic_magnet.xml +++ b/app/src/main/res/drawable/ic_magnet.xml @@ -3,9 +3,6 @@ android:height="46dp" android:viewportWidth="46" android:viewportHeight="46"> - diff --git a/app/src/main/res/drawable/ic_onboarding_battery.xml b/app/src/main/res/drawable/ic_onboarding_battery.xml new file mode 100644 index 0000000..3feaad1 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_battery.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_paused.xml b/app/src/main/res/drawable/ic_onboarding_paused.xml new file mode 100644 index 0000000..a645187 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_paused.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_policy.xml b/app/src/main/res/drawable/ic_onboarding_policy.xml new file mode 100644 index 0000000..61d22ab --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_policy.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_private.xml b/app/src/main/res/drawable/ic_onboarding_private.xml new file mode 100644 index 0000000..19025f0 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_private.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_onboarding_storage.xml b/app/src/main/res/drawable/ic_onboarding_storage.xml new file mode 100644 index 0000000..4c8afb9 --- /dev/null +++ b/app/src/main/res/drawable/ic_onboarding_storage.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_pressure.xml b/app/src/main/res/drawable/ic_pressure.xml index f9df38f..2f3ac59 100644 --- a/app/src/main/res/drawable/ic_pressure.xml +++ b/app/src/main/res/drawable/ic_pressure.xml @@ -3,9 +3,6 @@ android:height="46dp" android:viewportWidth="46" android:viewportHeight="46"> - - diff --git a/app/src/main/res/drawable/ic_rotation_icon.xml b/app/src/main/res/drawable/ic_rotation_icon.xml index 133f195..91c52fc 100644 --- a/app/src/main/res/drawable/ic_rotation_icon.xml +++ b/app/src/main/res/drawable/ic_rotation_icon.xml @@ -3,9 +3,6 @@ android:height="46dp" android:viewportWidth="46" android:viewportHeight="46"> - diff --git a/app/src/main/res/drawable/ic_source_location.xml b/app/src/main/res/drawable/ic_source_location.xml new file mode 100644 index 0000000..349c05b --- /dev/null +++ b/app/src/main/res/drawable/ic_source_location.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_steps.xml b/app/src/main/res/drawable/ic_steps.xml index ce5e352..4122d11 100644 --- a/app/src/main/res/drawable/ic_steps.xml +++ b/app/src/main/res/drawable/ic_steps.xml @@ -3,9 +3,6 @@ android:height="46dp" android:viewportWidth="46" android:viewportHeight="46"> - - - diff --git a/app/src/main/res/drawable/ic_water_drop.xml b/app/src/main/res/drawable/ic_water_drop.xml index 54f620b..68b84e3 100644 --- a/app/src/main/res/drawable/ic_water_drop.xml +++ b/app/src/main/res/drawable/ic_water_drop.xml @@ -3,9 +3,6 @@ android:height="46dp" android:viewportWidth="46" android:viewportHeight="46"> - + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0ff3be..684bc9d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -44,7 +44,6 @@ Selected folder: %1$s Back Start SensorBox - %1$d of %2$d SensorBox does not collect or upload your sensor or GPS recordings. Your measurements stay in the folder you select. Recording begins only after your action and remains visible through a foreground-service notification. https://tomasrepcik.dev/sensorbox/terms-of-use https://tomasrepcik.dev/sensorbox/privacy-policy @@ -150,6 +149,22 @@ Exclude from battery saving Sensor sampling Choose how often sensor values are captured + Recording + Location + App + Appearance + Storage + Details + Timing + Sources + Markers + Theme + Choose how the app follows the light and dark theme + Automatic + Light + Dark + Dynamic colors + Use colors from your Android wallpaper when available Fastest Game UI @@ -224,6 +239,12 @@ Send as text Send as file View logs + Open the complete diagnostic history + Share the diagnostic log file + Delete the retained diagnostic history + Diagnostic logs + Loading logs… + No diagnostic logs recorded Copy logs Clear logs Delete both retained diagnostic log files? This cannot be undone. diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index bd614a3..809272e 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,5 +1,6 @@ + diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml index f53d817..69ec66b 100644 --- a/wear/src/main/res/values/strings.xml +++ b/wear/src/main/res/values/strings.xml @@ -18,6 +18,8 @@ Check your phone browser GPS + Sources + Live values Start recording Back Waiting… @@ -30,6 +32,9 @@ Stop on low battery Wake lock Keep display on + Sensor sampling + Recording + Transfer Recording Display stays on Display may sleep @@ -44,6 +49,14 @@ %1$d file sent to phone %1$d files sent to phone + + %1$d source + %1$d sources + + + Start with %1$d source + Start with %1$d sources + https://tomasrepcik.dev/sensorbox/terms-of-use https://tomasrepcik.dev/sensorbox/privacy-policy diff --git a/wear/src/main/res/values/styles.xml b/wear/src/main/res/values/styles.xml index 24d2ea6..e7ac7e8 100644 --- a/wear/src/main/res/values/styles.xml +++ b/wear/src/main/res/values/styles.xml @@ -3,7 +3,7 @@ diff --git a/wear/src/screenshotTest/kotlin/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt b/wear/src/screenshotTest/kotlin/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt new file mode 100644 index 0000000..b345bb7 --- /dev/null +++ b/wear/src/screenshotTest/kotlin/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt @@ -0,0 +1,108 @@ +package com.motionapps.sensorbox.presentation.dashboard + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.Text +import com.android.tools.screenshot.PreviewTest +import com.motionapps.sensorbox.core.preferences.AppPreferences +import com.motionapps.sensorbox.core.preferences.DisplayPreferences +import com.motionapps.sensorbox.core.preferences.RecordingPreferences +import com.motionapps.sensorbox.domain.sensors.WearSensorDescriptor +import com.motionapps.sensorbox.ui.theme.WearSensorBoxTheme +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer + +@PreviewTest +@ReadmeWearPreview +@Composable +fun readmeWearDashboard() = WearReadmeFrame(WearDashboardState(route = WearRoute.MENU)) + +@PreviewTest +@ReadmeWearPreview +@Composable +fun readmeWearLivePicker() = WearReadmeFrame( + WearDashboardState(route = WearRoute.LIVE, sensors = readmeWearSensors()), +) + +@PreviewTest +@ReadmeWearPreview +@Composable +fun wearRecordSelection() = WearReadmeFrame( + WearDashboardState( + route = WearRoute.RECORD, + sensors = readmeWearSensors(), + selectedSensorIds = setOf(1), + includesGps = true, + ), +) + +@PreviewTest +@ReadmeWearPreview +@Composable +fun wearSettings() = WearReadmeFrame( + WearDashboardState( + route = WearRoute.SETTINGS, + preferences = AppPreferences( + recording = RecordingPreferences(sensorSamplingPeriod = 1), + display = DisplayPreferences(keepWearDisplayOn = true), + ), + ), +) + +@PreviewTest +@ReadmeWearPreview +@Composable +fun wearActiveRecording() = WearReadmeFrame( + WearDashboardState( + route = WearRoute.ACTIVE, + selectedSensorIds = setOf(1, 4), + includesGps = true, + ), +) + +@Composable +private fun WearReadmeFrame(state: WearDashboardState) { + WearSensorBoxTheme { + Box(Modifier.fillMaxSize()) { + WearDashboardScreen( + state = state, + chartModelProducer = remember { CartesianChartModelProducer() }, + accept = {}, + ) + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .width(72.dp) + .height(28.dp) + .background(Color.Black), + contentAlignment = Alignment.Center, + ) { + Text("10:09") + } + } + } +} + +private fun readmeWearSensors() = listOf( + WearSensorDescriptor(1, "Goldfish 3-axis Accelerometer", "Android"), + WearSensorDescriptor(4, "Goldfish 3-axis Gyroscope", "Android"), + WearSensorDescriptor(2, "Goldfish 3-axis Magnetic field sensor", "Android"), +) + +@Preview( + device = README_WEAR_DEVICE, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +private annotation class ReadmeWearPreview + +private const val README_WEAR_DEVICE = "spec:width=454px,height=454px,dpi=320,isRound=true" diff --git a/wear/src/screenshotTestDebug/reference/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/readmeWearDashboard_33ebe8f8_0.png b/wear/src/screenshotTestDebug/reference/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/readmeWearDashboard_33ebe8f8_0.png new file mode 100644 index 0000000..ccca0eb Binary files /dev/null and b/wear/src/screenshotTestDebug/reference/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/readmeWearDashboard_33ebe8f8_0.png differ diff --git a/wear/src/screenshotTestDebug/reference/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/readmeWearLivePicker_33ebe8f8_0.png b/wear/src/screenshotTestDebug/reference/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/readmeWearLivePicker_33ebe8f8_0.png new file mode 100644 index 0000000..9c9d11c Binary files /dev/null and b/wear/src/screenshotTestDebug/reference/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/readmeWearLivePicker_33ebe8f8_0.png differ diff --git a/wear/src/screenshotTestDebug/reference/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearActiveRecording_33ebe8f8_0.png b/wear/src/screenshotTestDebug/reference/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearActiveRecording_33ebe8f8_0.png new file mode 100644 index 0000000..e71409f Binary files /dev/null and b/wear/src/screenshotTestDebug/reference/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearActiveRecording_33ebe8f8_0.png differ diff --git a/wear/src/screenshotTestDebug/reference/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearRecordSelection_33ebe8f8_0.png b/wear/src/screenshotTestDebug/reference/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearRecordSelection_33ebe8f8_0.png new file mode 100644 index 0000000..e1a11b4 Binary files /dev/null and b/wear/src/screenshotTestDebug/reference/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearRecordSelection_33ebe8f8_0.png differ diff --git a/wear/src/screenshotTestDebug/reference/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearSettings_33ebe8f8_0.png b/wear/src/screenshotTestDebug/reference/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearSettings_33ebe8f8_0.png new file mode 100644 index 0000000..22cc874 Binary files /dev/null and b/wear/src/screenshotTestDebug/reference/com/motionapps/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviewsKt/wearSettings_33ebe8f8_0.png differ