Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This is the hard-cut Android 17 generation of the project. It does not retain th

| Sensor selection | Measurement setup |
|:---:|:---:|
| <img src="docs/images/sensorbox-phone-record.png" alt="SensorBox sensor selection with original icons" width="280"> | <img src="docs/images/sensorbox-phone-setup.png" alt="SensorBox measurement setup" width="280"> |
| <img src="docs/images/sensorbox-phone-record.png" alt="SensorBox source selection" width="280"> | <img src="docs/images/sensorbox-phone-setup.png" alt="SensorBox measurement setup" width="280"> |

| Wear dashboard | Wear live-sensor picker |
|:---:|:---:|
Expand All @@ -24,7 +24,13 @@ This is the hard-cut Android 17 generation of the project. It does not retain th
|:---:|:---:|:---:|
| <img src="docs/images/sensorbox-intro-lifecycle.png" alt="SensorBox Android lifecycle introduction" width="220"> | <img src="docs/images/sensorbox-intro-battery.png" alt="SensorBox battery optimization introduction" width="220"> | <img src="docs/images/sensorbox-intro-storage.png" alt="SensorBox recording-folder introduction" width="220"> |

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

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@

<application
android:name=".SensorBoxApp"
android:icon="@mipmap/ic_icon_square"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher_white_round"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme.Compose"
android:fullBackupContent="@xml/backup_descriptor"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ class MainActivity : ComponentActivity() {
LaunchedEffect(settingsViewModel) {
settingsViewModel.effects.collect(::handleSettingsEffect)
}
SensorBoxTheme {
SensorBoxTheme(
themeMode = settingsState.preferences.display.themeMode,
dynamicColor = settingsState.preferences.display.dynamicColors,
) {
SensorBoxApp(
mainState = mainState,
onboardingState = onboardingState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,10 @@ 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.fillMaxSize
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
Expand Down Expand Up @@ -42,7 +40,7 @@ fun AboutDialog(onDismiss: () -> 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),
)
Expand Down Expand Up @@ -89,12 +87,11 @@ fun OpenSourceLicensesScreen(onBack: () -> Unit, modifier: Modifier = Modifier)
}
var selectedLicense by remember { mutableStateOf<OpenSourceLicense?>(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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -57,60 +59,66 @@ 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 {
onIntent(RecordingIntent.AddAnnotation(it))
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,
) {
Expand Down Expand Up @@ -160,57 +168,52 @@ 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)
}
}

@Composable
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),
Expand All @@ -222,10 +225,14 @@ private fun SensorListToggle(expanded: Boolean, onClick: () -> Unit) {

@Composable
private fun RecordingSourceList(sources: List<String>) {
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,
)
}
}
}
Expand All @@ -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),
)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
}
}
Loading