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 |
|:---:|:---:|
-|
|
|
+|
|
|
| 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
|:---:|:---:|:---:|
|
|
|
|
-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