From 1884ba739b2c3e56c1550717147126a492905274 Mon Sep 17 00:00:00 2001 From: Foxpace Date: Sat, 29 Aug 2026 13:50:17 +0200 Subject: [PATCH] refactor: decompose large composable units --- README.md | 7 + WearOsLib/build.gradle.kts | 1 + .../protocol/RecordingCommandExchange.kt | 131 +++++ app/build.gradle.kts | 1 + .../presentation/main/AppErrorScreenTest.kt | 37 ++ .../main/MeasurementBrowserScreenTest.kt | 15 +- .../main/OpenSourceLicensesScreenTest.kt | 28 +- .../main/SensorDetailsScreenTest.kt | 1 + .../sensorbox/activities/MainActivity.kt | 123 ++-- .../AndroidMeasurementRepository.kt | 33 +- .../measurements/MeasurementMetadataParser.kt | 72 +++ .../sensorbox/di/DiagnosticsModule.kt | 5 + .../di/RecordingCommandExchangeModule.kt | 32 ++ .../sensorbox/di/RecordingModule.kt | 42 ++ .../DiagnosticsShareFilePreparer.kt | 33 ++ .../licenses/OpenSourceLicenseRepository.kt | 38 ++ .../measurements/MeasurementRepository.kt | 1 + .../paired/PairedRecordingCoordinator.kt | 174 ------ .../domain/paired/PairedRecordingModel.kt | 28 - .../paired/PhoneRecordingSessionObserver.kt | 11 +- .../domain/paired/PhoneWatchCommandHandler.kt | 16 +- .../paired/WatchRecordingResultInbox.kt | 38 -- .../domain/preview/DevicePreviewRepository.kt | 138 +++++ .../recording/RecordingArchiveRepository.kt | 11 +- .../recording/RecordingControlUseCase.kt | 130 ++++- .../sensors/RecordingSourceAvailability.kt | 84 +++ .../domain/sensors/WatchSensorCatalogStore.kt | 27 - .../presentation/main/AboutDialogBody.kt | 30 + .../presentation/main/AboutScreen.kt | 105 ---- .../presentation/main/AboutSetting.kt | 33 ++ .../main/ActiveRecordingComponents.kt | 250 +++++++++ .../main/ActiveRecordingScreen.kt | 237 +------- .../presentation/main/AppErrorScreen.kt | 64 +++ .../presentation/main/DiagnosticsSetting.kt | 82 +++ .../sensorbox/presentation/main/GpsDetails.kt | 92 +++ .../sensorbox/presentation/main/GpsPreview.kt | 78 +++ .../presentation/main/GpsSettings.kt | 29 + .../main/HardwareSensorDetails.kt | 90 +++ .../main/HardwareSensorPreview.kt | 80 +++ .../presentation/main/LiveSensorChart.kt | 234 ++++++++ .../presentation/main/MainContract.kt | 4 +- .../presentation/main/MainViewModel.kt | 20 +- .../main/MeasurementBrowserComponents.kt | 106 ++++ .../main/MeasurementBrowserScreen.kt | 524 ------------------ .../main/MeasurementBrowserViewModel.kt | 9 +- .../main/MeasurementDetailsComponents.kt | 46 ++ .../main/MeasurementDetailsScreen.kt | 29 + .../main/MeasurementFileComponents.kt | 88 +++ .../main/MeasurementFileScreen.kt | 62 +++ .../main/MeasurementsComponents.kt | 63 +++ .../presentation/main/MeasurementsScreen.kt | 24 + .../presentation/main/NumberPickerSetting.kt | 111 ++++ .../presentation/main/OnboardingComponents.kt | 267 +++++++++ .../presentation/main/OnboardingLayout.kt | 80 +++ .../presentation/main/OnboardingPages.kt | 56 ++ .../presentation/main/OnboardingScreen.kt | 389 +------------ .../presentation/main/OnboardingViewModel.kt | 47 +- .../main/OpenSourceLicenseComponents.kt | 51 ++ .../main/OpenSourceLicensesScreen.kt | 32 ++ .../presentation/main/PowerSettings.kt | 61 ++ .../presentation/main/RecordContent.kt | 75 +++ .../presentation/main/RecordScreen.kt | 292 +--------- .../presentation/main/RecordingContract.kt | 11 +- .../presentation/main/RecordingMessage.kt | 67 +++ .../main/RecordingSetupComponents.kt | 179 ++++++ .../main/RecordingSetupContent.kt | 66 +++ .../presentation/main/RecordingSetupScreen.kt | 257 +-------- .../presentation/main/RecordingSetupTopBar.kt | 48 ++ .../main/RecordingSourceComponents.kt | 141 +++++ .../presentation/main/RecordingViewModel.kt | 217 +++++--- .../presentation/main/SensorBoxApp.kt | 103 +--- .../presentation/main/SensorBoxButtons.kt | 109 ++++ .../presentation/main/SensorBoxComponents.kt | 304 ---------- .../presentation/main/SensorBoxRoutes.kt | 118 ++++ .../main/SensorBoxScreenComponents.kt | 139 +++++ .../main/SensorBoxSettingsComponents.kt | 80 +++ .../main/SensorDetailComponents.kt | 28 + .../presentation/main/SensorDetailsContent.kt | 62 +++ .../presentation/main/SensorDetailsScreen.kt | 324 +---------- .../main/SensorPreviewComponents.kt | 28 + .../presentation/main/SensorPreviewScreen.kt | 434 +-------------- .../main/SensorSelectionTopBar.kt | 46 ++ .../presentation/main/SettingsComponents.kt | 161 ++++++ .../presentation/main/SettingsContract.kt | 15 +- .../presentation/main/SettingsScreen.kt | 475 ++-------------- .../presentation/main/SettingsViewModel.kt | 101 +++- .../presentation/main/StoredSensorChart.kt | 242 ++++++++ .../presentation/main/ThemeModeSetting.kt | 20 + app/src/main/res/values/strings.xml | 10 + .../MeasurementMetadataParserTest.kt | 63 +++ .../paired/PairedRecordingCoordinatorTest.kt | 267 --------- .../DocumentRecordingArchiveRepositoryTest.kt | 17 +- .../recording/RecordingControlUseCaseTest.kt | 245 ++++++++ .../RecordingSourceAvailabilityTest.kt | 112 ++++ .../sensors/WatchSensorCatalogStoreTest.kt | 25 - .../presentation/main/FeatureViewModelTest.kt | 215 +++++-- .../main/MeasurementBrowserViewModelTest.kt | 10 +- .../sensorbox/core/error/AppFailureStore.kt | 24 + .../sensorbox/core/error/Diagnostics.kt | 4 - .../core/error/AppFailureStoreTest.kt | 70 +++ .../sensorbox/core/error/FileDiagnostics.kt | 8 - .../core/storage/NativeDocumentStorage.kt | 23 +- .../core/error/FileDiagnosticsTest.kt | 12 - .../session/RecordingSessionState.kt | 15 + .../session/RecordingSessionStore.kt | 48 ++ sensorservices/build.gradle.kts | 1 + .../sensorservices/di/StorageModule.kt | 5 + wear/build.gradle.kts | 1 + .../dashboard/WearAppErrorScreenTest.kt | 32 ++ .../sensorbox/activities/MainActivity.kt | 13 +- .../sensorbox/activities/MoveToMain.kt | 3 +- .../sensorbox/di/DiagnosticsModule.kt | 5 + .../di/RecordingCommandExchangeModule.kt | 32 ++ .../tomasrepcik/sensorbox/di/SyncModule.kt | 17 + .../sync/SyncWatchMeasurementsUseCase.kt | 10 +- .../sensorbox/presentation/WearButtons.kt | 59 ++ .../presentation/WearScreenComponents.kt | 65 +++ ...mponents.kt => WearSelectionComponents.kt} | 123 +--- .../dashboard/WearActiveComponents.kt | 48 ++ .../dashboard/WearActiveScreen.kt | 35 ++ .../dashboard/WearAppErrorScreen.kt | 60 ++ .../dashboard/WearDashboardComponents.kt | 35 ++ .../dashboard/WearDashboardContract.kt | 10 +- .../dashboard/WearDashboardScreen.kt | 307 +--------- .../dashboard/WearDashboardViewModel.kt | 97 ++-- .../dashboard/WearLiveComponents.kt | 104 ++++ .../presentation/dashboard/WearLiveScreen.kt | 21 + .../dashboard/WearRecordComponents.kt | 52 ++ .../dashboard/WearRecordScreen.kt | 32 ++ .../dashboard/WearSettingsScreen.kt | 104 ++++ .../presentation/menu/WearMenuContract.kt | 20 +- .../presentation/menu/WearMenuScreen.kt | 28 +- .../phonelaunch/PhoneLaunchContract.kt | 6 + .../phonelaunch/PhoneLaunchScreen.kt | 17 +- .../phonelaunch/PhoneLaunchStatusText.kt | 11 + .../phonelaunch/PhoneLaunchViewModel.kt | 23 +- wear/src/main/res/values/strings.xml | 7 + .../dashboard/ReadmeWearScreenshotPreviews.kt | 3 - .../dashboard/WearDashboardReducerTest.kt | 2 +- .../phonelaunch/PhoneLaunchViewModelTest.kt | 3 + 140 files changed, 6648 insertions(+), 4783 deletions(-) create mode 100644 WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/RecordingCommandExchange.kt create mode 100644 app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/AppErrorScreenTest.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/MeasurementMetadataParser.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/domain/diagnostics/DiagnosticsShareFilePreparer.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/domain/licenses/OpenSourceLicenseRepository.kt delete mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinator.kt delete mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/WatchRecordingResultInbox.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/domain/preview/DevicePreviewRepository.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/RecordingSourceAvailability.kt delete mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorCatalogStore.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutDialogBody.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutSetting.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ActiveRecordingComponents.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AppErrorScreen.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/DiagnosticsSetting.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsDetails.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsPreview.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsSettings.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/HardwareSensorDetails.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/HardwareSensorPreview.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/LiveSensorChart.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserComponents.kt delete mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreen.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementDetailsComponents.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementDetailsScreen.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementFileComponents.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementFileScreen.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementsComponents.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementsScreen.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/NumberPickerSetting.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingComponents.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingLayout.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingPages.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicenseComponents.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicensesScreen.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/PowerSettings.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordContent.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingMessage.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupComponents.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupContent.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupTopBar.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSourceComponents.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxButtons.kt delete mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxComponents.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxRoutes.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxScreenComponents.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxSettingsComponents.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailComponents.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsContent.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewComponents.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorSelectionTopBar.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsComponents.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/StoredSensorChart.kt create mode 100644 app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ThemeModeSetting.kt create mode 100644 app/src/test/java/com/tomasrepcik/sensorbox/data/measurements/MeasurementMetadataParserTest.kt delete mode 100644 app/src/test/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt create mode 100644 app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/RecordingControlUseCaseTest.kt create mode 100644 app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/RecordingSourceAvailabilityTest.kt delete mode 100644 app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorCatalogStoreTest.kt create mode 100644 core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/AppFailureStore.kt create mode 100644 core-common/src/test/kotlin/com/tomasrepcik/sensorbox/core/error/AppFailureStoreTest.kt create mode 100644 recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionState.kt create mode 100644 recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionStore.kt create mode 100644 wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreenTest.kt create mode 100644 wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt create mode 100644 wear/src/main/java/com/tomasrepcik/sensorbox/di/SyncModule.kt create mode 100644 wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearButtons.kt create mode 100644 wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearScreenComponents.kt rename wear/src/main/java/com/tomasrepcik/sensorbox/presentation/{WearComponents.kt => WearSelectionComponents.kt} (50%) create mode 100644 wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveComponents.kt create mode 100644 wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveScreen.kt create mode 100644 wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreen.kt create mode 100644 wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardComponents.kt create mode 100644 wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveComponents.kt create mode 100644 wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveScreen.kt create mode 100644 wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordComponents.kt create mode 100644 wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordScreen.kt create mode 100644 wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearSettingsScreen.kt create mode 100644 wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchStatusText.kt diff --git a/README.md b/README.md index bbda53d..aaea8ed 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,13 @@ ANDROID_HOME="$HOME/Library/Android/sdk" tools/emulator/run_wear_sync_test.sh The runner detects one phone and one watch automatically; `PHONE_SERIAL` and `WEAR_SERIAL` remain available when several devices are connected. It builds and installs once, refreshes the ADB bridge after installation, and launches each scenario on both devices. When an emulator transport exposes its paired node but does not propagate static capabilities, the instrumentation-only repository falls back to that connected node; file transfer still uses the production Channel client and receiver. Received files use app-internal storage only in debuggable builds; release builds continue to require the user-selected recording archive. +The phone-driven paired recording test runs instrumentation only on the phone. It discovers a real Wear sensor, starts and stops a watch recording over the production message protocol, asks Wear OS to sync measurements, and verifies the transferred metadata and sensor samples on the phone: + +```shell +PHONE_SERIAL=emulator-5554 WEAR_SERIAL=emulator-5556 \ + tools/emulator/run_phone_paired_recording_test.sh +``` + No Firebase project, Maps key, secrets file, or external storage permission is required. ## Dependencies diff --git a/WearOsLib/build.gradle.kts b/WearOsLib/build.gradle.kts index bd666e1..13200b2 100644 --- a/WearOsLib/build.gradle.kts +++ b/WearOsLib/build.gradle.kts @@ -51,6 +51,7 @@ dependencies { implementation(libs.kotlinx.serialization.json) ksp(libs.hilt.compiler) testImplementation(libs.junit) + testImplementation(libs.coroutines.test) testFixturesImplementation(project(":core-common")) testFixturesImplementation(libs.coroutines.core) } diff --git a/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/RecordingCommandExchange.kt b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/RecordingCommandExchange.kt new file mode 100644 index 0000000..5ae5f03 --- /dev/null +++ b/WearOsLib/src/main/java/com/tomasrepcik/sensorbox/wearoslib/protocol/RecordingCommandExchange.kt @@ -0,0 +1,131 @@ +package com.tomasrepcik.sensorbox.wearoslib.protocol + +import com.tomasrepcik.sensorbox.core.error.AppError +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppResult +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.ConcurrentHashMap + +fun interface RecordingCommandSender { + suspend fun send(command: WearCommand): AppResult +} + +fun interface RecordingResultReceiver { + fun receive(result: WearCommand.RecordingResult) +} + +class RecordingCommandExchange( + private val sendCommand: SendWearCommandUseCase, + private val peerCapability: String, + private val peerPath: String, + private val peerName: String, +) : RecordingCommandSender, + RecordingResultReceiver { + private val results = ConcurrentHashMap() + private val updates = MutableSharedFlow(extraBufferCapacity = RESULT_BUFFER_SIZE) + + @Suppress("ReturnCount") + override suspend fun send(command: WearCommand): AppResult { + val operation = command.recordingOperation() + ?: return AppResult.failure(AppError(AppErrorCode.VALIDATION, "Exchange recording command")) + val sessionId = checkNotNull(command.recordingSessionId()) + clear(sessionId, operation) + + var lastSendError: AppError? = null + var commandWasSent = false + repeat(ATTEMPT_COUNT) { retryCount -> + when (val sent = sendCommand(peerCapability, peerPath, command)) { + is AppResult.Failure -> lastSendError = sent.error + + is AppResult.Success -> { + commandWasSent = true + lastSendError = null + val result = withTimeoutOrNull(RESULT_TIMEOUT_MILLIS / ATTEMPT_COUNT) { + await(sessionId, operation) + } + if (result != null) return result.toAppResult(retryCount) + } + } + } + + if (!commandWasSent && lastSendError != null) return AppResult.failure(checkNotNull(lastSendError)) + return timeout(sessionId, operation, commandWasSent) + } + + override fun receive(result: WearCommand.RecordingResult) { + results[result.key()] = result + updates.tryEmit(result) + } + + private fun clear(sessionId: String, operation: WearRecordingOperation) { + results.remove(ResultKey(sessionId, operation)) + } + + private suspend fun await(sessionId: String, operation: WearRecordingOperation): WearCommand.RecordingResult { + val key = ResultKey(sessionId, operation) + results[key]?.let { return it } + return updates.first { result -> result.key() == key } + } + + private fun WearCommand.RecordingResult.toAppResult(retryCount: Int): AppResult = + if (outcome == WearRecordingOutcome.SUCCEEDED) { + AppResult.success(Unit) + } else { + AppResult.failure( + AppError( + code = errorCode ?: AppErrorCode.UNKNOWN, + operation = errorOperation ?: "Handle $peerName $operation result", + diagnosticMessage = errorMessage ?: "$peerName $operation failed", + context = errorContext + mapOf( + "source" to peerName, + "sessionId" to sessionId, + "retryCount" to retryCount.toString(), + "failureCount" to failureCount.toString(), + "protocolVersion" to WearCommandCodec.PROTOCOL_VERSION.toString(), + ), + ), + ) + } + + private fun timeout( + sessionId: String, + operation: WearRecordingOperation, + commandWasSent: Boolean, + ): AppResult = AppResult.failure( + AppError( + code = AppErrorCode.TIMEOUT, + operation = "Await $peerName $operation result", + diagnosticMessage = "$peerName $operation result timed out", + context = mapOf( + "source" to peerName, + "sessionId" to sessionId, + "retryCount" to RETRY_COUNT.toString(), + "protocolVersion" to WearCommandCodec.PROTOCOL_VERSION.toString(), + "commandWasSent" to commandWasSent.toString(), + ), + isRetryable = true, + ), + ) + + private fun WearCommand.RecordingResult.key() = ResultKey(sessionId, operation) + + private data class ResultKey(val sessionId: String, val operation: WearRecordingOperation) + + private companion object { + const val RESULT_TIMEOUT_MILLIS = 5_000L + const val RETRY_COUNT = 2 + const val ATTEMPT_COUNT = RETRY_COUNT + 1 + const val RESULT_BUFFER_SIZE = 16 + } +} + +fun AppError.wasRecordingCommandSent(): Boolean = + code == AppErrorCode.TIMEOUT && context["commandWasSent"] == true.toString() + +private fun WearCommand.recordingOperation(): WearRecordingOperation? = when (this) { + is WearCommand.StartRecording -> WearRecordingOperation.START + is WearCommand.StopRecording -> WearRecordingOperation.STOP + else -> null +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9b15af8..f5cd3a6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,6 +61,7 @@ android { dependencies { implementation(project(":core")) implementation(project(":sensorservices")) + implementation(project(":recording-core")) implementation(project(":wearoslib")) implementation(libs.androidx.core.ktx) diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/AppErrorScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/AppErrorScreenTest.kt new file mode 100644 index 0000000..fc471eb --- /dev/null +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/AppErrorScreenTest.kt @@ -0,0 +1,37 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.ui.theme.SensorBoxTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class AppErrorScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun givenStorageFailureWhenRenderedThenLocalizedMessageCanBeDismissed() { + // Given + var dismissed = false + + // When + composeRule.setContent { + SensorBoxTheme { + AppErrorScreen(AppErrorCode.STORAGE) { dismissed = true } + } + } + + // Then + composeRule.onNodeWithText("Something went wrong").assertIsDisplayed() + composeRule.onNodeWithText( + "Local storage could not be read or updated. The error was saved in diagnostics.", + ).assertIsDisplayed() + composeRule.onNodeWithText("Close").performClick() + assertTrue(dismissed) + } +} diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreenTest.kt index 7de1aa4..2d8c61b 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreenTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreenTest.kt @@ -84,6 +84,9 @@ class MeasurementBrowserScreenTest { ) composeRule.onNodeWithText("2 chart samples").assertIsDisplayed() + composeRule.onNodeWithText("Sensor details").assertIsDisplayed() + composeRule.onNodeWithText("Bosch accelerometer").assertIsDisplayed() + composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Zoom in")) composeRule.onNodeWithText("x, y, z").assertIsDisplayed() composeRule.onNodeWithText("Zoom out").assertIsDisplayed() composeRule.onNodeWithText("Show all").assertIsDisplayed() @@ -115,7 +118,11 @@ class MeasurementBrowserScreenTest { composeRule.setContent { SensorBoxTheme { MeasurementFileScreen( - state = MeasurementBrowserState(selectedFile = file, selectedFileContent = content), + state = MeasurementBrowserState( + selectedMeasurement = details, + selectedFile = file, + selectedFileContent = content, + ), onIntent = onIntent, onBack = {}, ) @@ -131,6 +138,12 @@ class MeasurementBrowserScreenTest { summary, listOf(MeasurementMetadataEntry("device.model", "Pixel fixture")), listOf(sensorFile, gpsFile), + sensorMetadataByFile = mapOf( + sensorFile.id to listOf( + MeasurementMetadataEntry("sensor", "Bosch accelerometer"), + MeasurementMetadataEntry("writtenSamples", "2"), + ), + ), ) } } diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicensesScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicensesScreenTest.kt index 8ce47f7..48c1979 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicensesScreenTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicensesScreenTest.kt @@ -4,7 +4,12 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import com.tomasrepcik.sensorbox.ui.theme.SensorBoxTheme +import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicense import org.junit.Rule import org.junit.Test @@ -16,7 +21,28 @@ class OpenSourceLicensesScreenTest { fun givenGeneratedLicenseMetadataWhenLicenseIsOpenedThenItsTextIsShown() { composeRule.setContent { SensorBoxTheme { - OpenSourceLicensesScreen(onBack = {}) + var state by remember { + mutableStateOf( + SettingsState( + openSourceLicenses = listOf( + OpenSourceLicense( + "Debug License Info", + "Licenses are only provided in build variants (e.g. release) where the Android " + + "Gradle Plugin generates an app dependency list.", + ), + ), + ), + ) + } + OpenSourceLicensesScreen( + state = state, + onIntent = { intent -> + if (intent is SettingsIntent.SelectOpenSourceLicense) { + state = state.copy(selectedLicenseName = intent.name) + } + }, + onBack = {}, + ) } } diff --git a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreenTest.kt b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreenTest.kt index 38477d7..c4ab04d 100644 --- a/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreenTest.kt +++ b/app/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreenTest.kt @@ -43,6 +43,7 @@ class SensorDetailsScreenTest { ), onBack = {}, onPreview = {}, + onIntent = {}, ) } } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt b/app/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt index f450cb9..0876cd0 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt @@ -1,6 +1,7 @@ package com.tomasrepcik.sensorbox.activities import android.annotation.SuppressLint +import android.app.Activity import android.content.ClipData import android.content.ClipboardManager import android.content.Intent @@ -14,14 +15,12 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.core.content.FileProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tomasrepcik.sensorbox.R import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticsStore import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.core.error.flatMap +import com.tomasrepcik.sensorbox.domain.recording.RecordingArchiveSelection import com.tomasrepcik.sensorbox.presentation.main.MainViewModel import com.tomasrepcik.sensorbox.presentation.main.MeasurementBrowserEffect import com.tomasrepcik.sensorbox.presentation.main.MeasurementBrowserViewModel @@ -34,13 +33,9 @@ import com.tomasrepcik.sensorbox.presentation.main.SettingsEffect import com.tomasrepcik.sensorbox.presentation.main.SettingsViewModel import com.tomasrepcik.sensorbox.ui.theme.SensorBoxTheme import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { - @Inject - lateinit var diagnosticsStore: DiagnosticsStore - private val mainViewModel: MainViewModel by viewModels() private val onboardingViewModel: OnboardingViewModel by viewModels() private val recordingViewModel: RecordingViewModel by viewModels() @@ -48,14 +43,25 @@ class MainActivity : ComponentActivity() { private val settingsViewModel: SettingsViewModel by viewModels() private var recordingArchiveRequestOwner = RecordingArchiveRequestOwner.RECORDING private val directoryPicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + val selection = if (it.resultCode == Activity.RESULT_OK) { + it.data?.data?.let { uri -> + RecordingArchiveSelection.Selected(uri.toString(), it.data?.flags ?: 0) + } ?: RecordingArchiveSelection.Cancelled + } else { + RecordingArchiveSelection.Cancelled + } when (recordingArchiveRequestOwner) { - RecordingArchiveRequestOwner.ONBOARDING -> onboardingViewModel.handleRecordingArchiveResult(it.data) - RecordingArchiveRequestOwner.RECORDING -> recordingViewModel.handleRecordingArchiveResult(it.data) + RecordingArchiveRequestOwner.ONBOARDING -> onboardingViewModel.handleRecordingArchiveResult(selection) + RecordingArchiveRequestOwner.RECORDING -> recordingViewModel.handleRecordingArchiveResult(selection) } } private val permissionRequest = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { recordingViewModel.handlePermissionResult() } + private val locationPreviewPermissionRequest = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + recordingViewModel.handleLocationPreviewPermissionResult() + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -93,6 +99,7 @@ class MainActivity : ComponentActivity() { onRecordingIntent = recordingViewModel::accept, onMeasurementBrowserIntent = measurementBrowserViewModel::onIntent, onSettingsIntent = settingsViewModel::accept, + onDismissFailure = mainViewModel::dismissFailure, ) } } @@ -101,9 +108,14 @@ class MainActivity : ComponentActivity() { private fun handleOnboardingEffect(effect: OnboardingEffect) { when (effect) { OnboardingEffect.PickRecordingArchive -> openRecordingArchivePicker(RecordingArchiveRequestOwner.ONBOARDING) + OnboardingEffect.OpenPrivacyPolicy -> openWebPage(getString(R.string.link_privacy_policy)) + OnboardingEffect.OpenTermsOfUse -> openWebPage(getString(R.string.link_terms)) - OnboardingEffect.RequestBatteryOptimizationExemption -> requestBatteryOptimizationExemption() + + OnboardingEffect.RequestBatteryOptimizationExemption -> + requestBatteryOptimizationExemption(onboardingViewModel::reportFailure) + is OnboardingEffect.Navigate -> mainViewModel.navigate(effect.route) } } @@ -116,18 +128,35 @@ class MainActivity : ComponentActivity() { is RecordingEffect.RequestPermissions -> appResult(AppErrorCode.PERMISSION, "Request app permissions") { permissionRequest.launch(effect.permissions.toTypedArray()) - } + }.onFailure(recordingViewModel::reportFailure) + + RecordingEffect.RequestLocationPreviewPermission -> appResult( + AppErrorCode.PERMISSION, + "Request location preview permission", + ) { + locationPreviewPermissionRequest.launch( + arrayOf( + android.Manifest.permission.ACCESS_FINE_LOCATION, + android.Manifest.permission.ACCESS_COARSE_LOCATION, + ), + ) + }.onFailure(recordingViewModel::reportFailure) } } private fun handleSettingsEffect(effect: SettingsEffect) { when (effect) { - SettingsEffect.RequestBatteryOptimizationExemption -> requestBatteryOptimizationExemption() - SettingsEffect.ShareDiagnosticsText -> shareDiagnosticsText() - SettingsEffect.ShareDiagnosticsFile -> shareDiagnosticsFile() + SettingsEffect.RequestBatteryOptimizationExemption -> + requestBatteryOptimizationExemption(settingsViewModel::reportFailure) + + is SettingsEffect.ShareDiagnosticsText -> shareDiagnosticsText(effect.text) + + is SettingsEffect.ShareDiagnosticsFile -> shareDiagnosticsFile(effect) + is SettingsEffect.CopyDiagnosticsText -> copyDiagnostics(effect.text) + SettingsEffect.DiagnosticsCleared -> showToast(R.string.diagnostics_cleared) - is SettingsEffect.DiagnosticsFailed -> showToast(R.string.diagnostics_operation_failed) + is SettingsEffect.Navigate -> mainViewModel.navigate(effect.route) } } @@ -142,15 +171,18 @@ class MainActivity : ComponentActivity() { recordingArchiveRequestOwner = owner appResult(AppErrorCode.EXTERNAL_ACTION, "Open recording archive picker") { directoryPicker.launch(recordingArchiveIntent()) - } + }.onFailure(::reportRecordingArchivePickerFailure) } private fun openWebPage(url: String) { launchExternalIntent(Intent(Intent.ACTION_VIEW, Uri.parse(url)), "Open web page") + .onFailure(onboardingViewModel::reportFailure) } @SuppressLint("BatteryLife") - private fun requestBatteryOptimizationExemption() { + private fun requestBatteryOptimizationExemption( + reportFailure: (com.tomasrepcik.sensorbox.core.error.AppError) -> Unit, + ) { val intent = Intent( Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:$packageName"), @@ -159,39 +191,27 @@ class MainActivity : ComponentActivity() { launchExternalIntent( Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS), "Open battery optimization settings", - ) + ).onFailure(reportFailure) } } - private fun shareDiagnosticsText() { - diagnosticsStore.readText().fold( - onSuccess = { diagnostics -> - val intent = Intent(Intent.ACTION_SEND) - .setType("text/plain") - .putExtra(Intent.EXTRA_SUBJECT, getString(R.string.diagnostics_title)) - .putExtra(Intent.EXTRA_TEXT, diagnostics) - launchShareIntent(intent).onFailure { showDiagnosticsShareFailure() } - }, - onFailure = { showDiagnosticsShareFailure() }, - ) + private fun shareDiagnosticsText(text: String) { + val intent = Intent(Intent.ACTION_SEND) + .setType("text/plain") + .putExtra(Intent.EXTRA_SUBJECT, getString(R.string.diagnostics_title)) + .putExtra(Intent.EXTRA_TEXT, text) + launchShareIntent(intent).onFailure(settingsViewModel::reportFailure) } - private fun shareDiagnosticsFile() { - diagnosticsStore.exportFile().fold( - onSuccess = { file -> - appResult(AppErrorCode.EXTERNAL_ACTION, "Prepare diagnostics file") { - val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file) - val intent = Intent(Intent.ACTION_SEND) - .setType("text/plain") - .putExtra(Intent.EXTRA_SUBJECT, getString(R.string.diagnostics_title)) - .putExtra(Intent.EXTRA_STREAM, uri) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - intent.clipData = ClipData.newUri(contentResolver, file.name, uri) - intent - }.flatMap(::launchShareIntent).onFailure { showDiagnosticsShareFailure() } - }, - onFailure = { showDiagnosticsShareFailure() }, - ) + private fun shareDiagnosticsFile(effect: SettingsEffect.ShareDiagnosticsFile) { + val uri = Uri.parse(effect.contentUri) + val intent = Intent(Intent.ACTION_SEND) + .setType(effect.mimeType) + .putExtra(Intent.EXTRA_SUBJECT, getString(R.string.diagnostics_title)) + .putExtra(Intent.EXTRA_STREAM, uri) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.clipData = ClipData.newUri(contentResolver, effect.displayName, uri) + launchShareIntent(intent).onFailure(settingsViewModel::reportFailure) } private fun launchShareIntent(intent: Intent): AppResult = @@ -204,10 +224,6 @@ class MainActivity : ComponentActivity() { startActivity(intent) } - private fun showDiagnosticsShareFailure() { - showToast(R.string.diagnostics_share_failed) - } - private fun copyDiagnostics(text: String) { appResult(AppErrorCode.EXTERNAL_ACTION, "Copy diagnostics") { getSystemService(ClipboardManager::class.java).setPrimaryClip( @@ -215,10 +231,17 @@ class MainActivity : ComponentActivity() { ) }.fold( onSuccess = { showToast(R.string.diagnostics_copied) }, - onFailure = { showToast(R.string.diagnostics_operation_failed) }, + onFailure = settingsViewModel::reportFailure, ) } + private fun reportRecordingArchivePickerFailure(error: com.tomasrepcik.sensorbox.core.error.AppError) { + when (recordingArchiveRequestOwner) { + RecordingArchiveRequestOwner.ONBOARDING -> onboardingViewModel.reportFailure(error) + RecordingArchiveRequestOwner.RECORDING -> recordingViewModel.reportFailure(error) + } + } + private fun showToast(message: Int) { Toast.makeText(this, message, Toast.LENGTH_LONG).show() } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/AndroidMeasurementRepository.kt b/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/AndroidMeasurementRepository.kt index 0476208..6f96b7d 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/AndroidMeasurementRepository.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/AndroidMeasurementRepository.kt @@ -10,7 +10,6 @@ import com.tomasrepcik.sensorbox.domain.measurements.MeasurementDetails import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileContent import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileKind import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileSummary -import com.tomasrepcik.sensorbox.domain.measurements.MeasurementMetadataEntry import com.tomasrepcik.sensorbox.domain.measurements.MeasurementRepository import com.tomasrepcik.sensorbox.domain.measurements.MeasurementSummary import com.tomasrepcik.sensorbox.domain.measurements.SensorSeriesSample @@ -18,7 +17,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -47,14 +45,16 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva val directory = measurementDirectory(measurementId) val metadataFile = directory.findFile(METADATA_FILE)?.takeIf(DocumentFile::isFile) val metadata = metadataFile?.let(::readDocumentText).orEmpty() + val parsedMetadata = MeasurementMetadataParser.parse(metadata) val files = directory.listFiles() .filter { it.isFile && it.name != METADATA_FILE } .mapNotNull(::createMeasurementFileSummary) .sortedWith(compareBy(MeasurementFileSummary::kind, MeasurementFileSummary::name)) MeasurementDetails( summary = createMeasurementSummary(directory), - metadata = parseMetadataEntries(metadata), + metadata = parsedMetadata.session, files = files, + sensorMetadataByFile = parsedMetadata.bySensorFile, ) } } @@ -112,31 +112,6 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva return MeasurementFileSummary(name, formatFileDisplayName(name), kind, document.length()) } - private fun parseMetadataEntries(value: String): List { - if (value.isBlank()) return emptyList() - val result = mutableListOf() - collectMetadataEntries(parseMetadataObject(value), prefix = "", destination = result) - return result - } - - private fun collectMetadataEntries( - element: JsonElement, - prefix: String, - destination: MutableList, - ) { - when (element) { - is JsonObject -> element.forEach { (key, value) -> - collectMetadataEntries(value, prefix.appendMetadataKey(key), destination) - } - - is JsonArray -> element.forEachIndexed { index, value -> - collectMetadataEntries(value, "$prefix[$index]", destination) - } - - is JsonPrimitive -> destination += MeasurementMetadataEntry(prefix, element.content) - } - } - private fun parseSensorSeries( document: DocumentFile, anchor: SensorTimeAnchor?, @@ -260,8 +235,6 @@ class AndroidMeasurementRepository @Inject constructor(@ApplicationContext priva private fun JsonElement.asPrimitive(): JsonPrimitive? = this as? JsonPrimitive - private fun String.appendMetadataKey(value: String): String = if (isEmpty()) value else "$this.$value" - private fun formatFileDisplayName(value: String): String = value.substringBeforeLast('.') .replace('_', ' ') .replaceFirstChar(Char::titlecase) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/MeasurementMetadataParser.kt b/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/MeasurementMetadataParser.kt new file mode 100644 index 0000000..e698c71 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/data/measurements/MeasurementMetadataParser.kt @@ -0,0 +1,72 @@ +package com.tomasrepcik.sensorbox.data.measurements + +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementMetadataEntry +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject + +internal object MeasurementMetadataParser { + private val json = Json { ignoreUnknownKeys = true } + + fun parse(value: String): ParsedMeasurementMetadata { + if (value.isBlank()) return ParsedMeasurementMetadata.EMPTY + val root = json.parseToJsonElement(value).jsonObject + return ParsedMeasurementMetadata( + session = flatten(JsonObject(root.filterKeys { key -> key !in SENSOR_KEYS })), + bySensorFile = sensorMetadata(root), + ) + } + + private fun sensorMetadata(root: JsonObject): Map> { + val ranges = root[RANGES_KEY] as? JsonArray ?: JsonArray(emptyList()) + val sensorFiles = root[SENSOR_FILES_KEY] as? JsonArray ?: JsonArray(emptyList()) + return sensorFiles.mapIndexedNotNull { index, element -> + val file = element as? JsonObject ?: return@mapIndexedNotNull null + val fileName = (file[FILE_NAME_KEY] as? JsonPrimitive)?.contentOrNull + ?: return@mapIndexedNotNull null + val details = buildList { + ranges.getOrNull(index)?.let { addAll(flatten(it)) } + addAll(flatten(JsonObject(file.filterKeys { key -> key != FILE_NAME_KEY }))) + } + fileName to details + }.toMap() + } + + private fun flatten(element: JsonElement): List = buildList { + collect(element, prefix = "", destination = this) + } + + private fun collect(element: JsonElement, prefix: String, destination: MutableList) { + when (element) { + is JsonObject -> element.forEach { (key, value) -> + collect(value, prefix.appendKey(key), destination) + } + + is JsonArray -> element.forEachIndexed { index, value -> + collect(value, "$prefix[$index]", destination) + } + + is JsonPrimitive -> destination += MeasurementMetadataEntry(prefix, element.content) + } + } + + private fun String.appendKey(value: String): String = if (isEmpty()) value else "$this.$value" + + private const val RANGES_KEY = "ranges" + private const val SENSOR_FILES_KEY = "sensorFiles" + private const val FILE_NAME_KEY = "fileName" + private val SENSOR_KEYS = setOf(RANGES_KEY, SENSOR_FILES_KEY) +} + +internal data class ParsedMeasurementMetadata( + val session: List, + val bySensorFile: Map>, +) { + companion object { + val EMPTY = ParsedMeasurementMetadata(emptyList(), emptyMap()) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt b/app/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt index fabf44f..517a3a1 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt @@ -4,6 +4,7 @@ import android.app.Application import android.content.Context import android.content.pm.ApplicationInfo import android.os.Build +import com.tomasrepcik.sensorbox.core.error.AppFailureStore import com.tomasrepcik.sensorbox.core.error.CompositeDiagnosticLogger import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger import com.tomasrepcik.sensorbox.core.error.DiagnosticMetadata @@ -45,6 +46,10 @@ object DiagnosticsModule { fun provideDiagnosticLogger(diagnostics: FileDiagnostics): DiagnosticLogger = CompositeDiagnosticLogger(diagnostics, LogcatDiagnosticLogger()) + @Provides + @Singleton + fun provideAppFailureStore(diagnosticLogger: DiagnosticLogger): AppFailureStore = AppFailureStore(diagnosticLogger) + @Provides fun provideDiagnosticsStore(diagnostics: FileDiagnostics): DiagnosticsStore = diagnostics } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt b/app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt new file mode 100644 index 0000000..48f0d09 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt @@ -0,0 +1,32 @@ +package com.tomasrepcik.sensorbox.di + +import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingCommandExchange +import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingCommandSender +import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingResultReceiver +import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object RecordingCommandExchangeModule { + @Provides + @Singleton + fun provideRecordingCommandExchange(sendCommand: SendWearCommandUseCase) = RecordingCommandExchange( + sendCommand = sendCommand, + peerCapability = WEAR_APP_CAPABILITY, + peerPath = WEAR_MESSAGE_PATH, + peerName = "watch", + ) + + @Provides + fun provideRecordingCommandSender(exchange: RecordingCommandExchange): RecordingCommandSender = exchange + + @Provides + fun provideRecordingResultReceiver(exchange: RecordingCommandExchange): RecordingResultReceiver = exchange +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt b/app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt index 7e00246..492ca35 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/di/RecordingModule.kt @@ -1,17 +1,28 @@ package com.tomasrepcik.sensorbox.di import com.tomasrepcik.sensorbox.data.measurements.AndroidMeasurementRepository +import com.tomasrepcik.sensorbox.domain.diagnostics.AndroidDiagnosticsShareFilePreparer +import com.tomasrepcik.sensorbox.domain.diagnostics.DiagnosticsShareFilePreparer +import com.tomasrepcik.sensorbox.domain.licenses.AndroidOpenSourceLicenseRepository +import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicenseRepository import com.tomasrepcik.sensorbox.domain.measurements.MeasurementRepository +import com.tomasrepcik.sensorbox.domain.preview.AndroidDevicePreviewRepository +import com.tomasrepcik.sensorbox.domain.preview.DevicePreviewRepository import com.tomasrepcik.sensorbox.domain.recording.AndroidPhoneRecordingController import com.tomasrepcik.sensorbox.domain.recording.DefaultRecordingControlUseCase import com.tomasrepcik.sensorbox.domain.recording.DocumentRecordingArchiveRepository +import com.tomasrepcik.sensorbox.domain.recording.PeerRecordingControl import com.tomasrepcik.sensorbox.domain.recording.PhoneRecordingController +import com.tomasrepcik.sensorbox.domain.recording.PhoneRecordingSessionControl import com.tomasrepcik.sensorbox.domain.recording.RecordingArchiveRepository import com.tomasrepcik.sensorbox.domain.recording.RecordingControlUseCase import com.tomasrepcik.sensorbox.domain.recording.RecordingPermissionUseCase import com.tomasrepcik.sensorbox.domain.recording.RecordingPermissionsUseCase +import com.tomasrepcik.sensorbox.domain.sensors.AvailableRecordingSourcesUseCase import com.tomasrepcik.sensorbox.domain.sensors.AvailableSensorsUseCase import com.tomasrepcik.sensorbox.domain.sensors.GetAvailableSensorsUseCase +import com.tomasrepcik.sensorbox.domain.sensors.ReceiveWatchSensorsUseCase +import com.tomasrepcik.sensorbox.domain.sensors.RecordingSourceAvailability import com.tomasrepcik.sensorbox.presentation.main.AndroidElapsedRealtimeClock import com.tomasrepcik.sensorbox.presentation.main.ElapsedRealtimeClock import dagger.Binds @@ -35,6 +46,16 @@ abstract class RecordingModule { @Binds abstract fun bindAvailableSensorsUseCase(implementation: GetAvailableSensorsUseCase): AvailableSensorsUseCase + @Binds + abstract fun bindAvailableRecordingSourcesUseCase( + implementation: RecordingSourceAvailability, + ): AvailableRecordingSourcesUseCase + + @Binds + abstract fun bindReceiveWatchSensorsUseCase( + implementation: RecordingSourceAvailability, + ): ReceiveWatchSensorsUseCase + @Binds abstract fun bindRecordingPermissionsUseCase( implementation: RecordingPermissionUseCase, @@ -43,9 +64,30 @@ abstract class RecordingModule { @Binds abstract fun bindRecordingControlUseCase(implementation: DefaultRecordingControlUseCase): RecordingControlUseCase + @Binds + abstract fun bindPhoneRecordingSessionControl( + implementation: DefaultRecordingControlUseCase, + ): PhoneRecordingSessionControl + + @Binds + abstract fun bindPeerRecordingControl(implementation: DefaultRecordingControlUseCase): PeerRecordingControl + @Binds abstract fun bindMeasurementRepository(implementation: AndroidMeasurementRepository): MeasurementRepository @Binds abstract fun bindElapsedRealtimeClock(implementation: AndroidElapsedRealtimeClock): ElapsedRealtimeClock + + @Binds + abstract fun bindDevicePreviewRepository(implementation: AndroidDevicePreviewRepository): DevicePreviewRepository + + @Binds + abstract fun bindOpenSourceLicenseRepository( + implementation: AndroidOpenSourceLicenseRepository, + ): OpenSourceLicenseRepository + + @Binds + abstract fun bindDiagnosticsShareFilePreparer( + implementation: AndroidDiagnosticsShareFilePreparer, + ): DiagnosticsShareFilePreparer } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/diagnostics/DiagnosticsShareFilePreparer.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/diagnostics/DiagnosticsShareFilePreparer.kt new file mode 100644 index 0000000..666646f --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/diagnostics/DiagnosticsShareFilePreparer.kt @@ -0,0 +1,33 @@ +package com.tomasrepcik.sensorbox.domain.diagnostics + +import android.content.Context +import androidx.core.content.FileProvider +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.error.appResult +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +data class DiagnosticsShareFile(val contentUri: String, val displayName: String, val mimeType: String) + +interface DiagnosticsShareFilePreparer { + fun prepare(text: String): AppResult +} + +class AndroidDiagnosticsShareFilePreparer @Inject constructor(@ApplicationContext private val context: Context) : + DiagnosticsShareFilePreparer { + override fun prepare(text: String): AppResult = appResult( + AppErrorCode.STORAGE, + "Prepare diagnostics share file", + ) { + val directory = context.filesDir.resolve("diagnostics").apply { mkdirs() } + val file = directory.resolve(FILE_NAME).apply { writeText(text) } + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + DiagnosticsShareFile(uri.toString(), file.name, MIME_TYPE) + } + + private companion object { + const val FILE_NAME = "sensorbox-diagnostics-export.jsonl" + const val MIME_TYPE = "application/x-ndjson" + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/licenses/OpenSourceLicenseRepository.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/licenses/OpenSourceLicenseRepository.kt new file mode 100644 index 0000000..1382396 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/licenses/OpenSourceLicenseRepository.kt @@ -0,0 +1,38 @@ +package com.tomasrepcik.sensorbox.domain.licenses + +import android.content.Context +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.error.appResult +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +data class OpenSourceLicense(val name: String, val text: String) + +interface OpenSourceLicenseRepository { + fun load(): AppResult> +} + +class AndroidOpenSourceLicenseRepository @Inject constructor(@ApplicationContext private val context: Context) : + OpenSourceLicenseRepository { + override fun load(): AppResult> = appResult( + AppErrorCode.STORAGE, + "Load open source licenses", + ) { + val resources = context.resources + val licenseBytes = resources.openRawResource(R.raw.third_party_licenses).use { it.readBytes() } + resources.openRawResource(R.raw.third_party_license_metadata).bufferedReader().useLines { lines -> + lines.mapNotNull { line -> + val location = line.substringBefore(' ') + val name = line.substringAfter(' ', missingDelimiterValue = "").trim() + val offset = location.substringBefore(':').toIntOrNull() + val length = location.substringAfter(':', missingDelimiterValue = "").toIntOrNull() + if (name.isEmpty() || offset == null || length == null || offset + length > licenseBytes.size) { + return@mapNotNull null + } + OpenSourceLicense(name, licenseBytes.decodeToString(offset, offset + length).trim()) + }.distinctBy(OpenSourceLicense::name).sortedBy { it.name.lowercase() }.toList() + } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurements/MeasurementRepository.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurements/MeasurementRepository.kt index e736c00..bb490fe 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurements/MeasurementRepository.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/measurements/MeasurementRepository.kt @@ -22,6 +22,7 @@ data class MeasurementDetails( val summary: MeasurementSummary, val metadata: List, val files: List, + val sensorMetadataByFile: Map> = emptyMap(), ) data class MeasurementMetadataEntry(val name: String, val value: String) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinator.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinator.kt deleted file mode 100644 index f6c4b40..0000000 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinator.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.tomasrepcik.sensorbox.domain.paired - -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.core.error.combineAppResults -import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent -import com.tomasrepcik.sensorbox.domain.recording.PhoneRecordingController -import com.tomasrepcik.sensorbox.domain.recording.RecordingSetup -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH -import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOperation -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason -import com.tomasrepcik.sensorbox.wearoslib.protocol.recordingSessionId -import kotlinx.coroutines.withTimeoutOrNull -import java.util.UUID -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class PairedRecordingCoordinator @Inject constructor( - private val phoneRecording: PhoneRecordingController, - private val sendCommand: SendWearCommandUseCase, - private val watchResults: WatchRecordingResultInbox, - private val diagnosticLogger: DiagnosticLogger, -) { - private var state: PairedRecordingState = PairedRecordingState.Idle - - @Suppress("ReturnCount") - suspend fun start(request: RecordingSetup): AppResult { - if (state !is PairedRecordingState.Idle) return conflict("Start recording") - - val sessionId = UUID.randomUUID().toString() - val includesWatch = request.includesWatchRecording() - val starting = PairedRecordingState.Starting(sessionId, includesWatch) - state = starting - - val startedPhone = when (val phoneStart = phoneRecording.start(sessionId, request)) { - is AppResult.Failure -> { - state = PairedRecordingState.Idle - return record(phoneStart) - } - - is AppResult.Success -> phoneStart.value - } - - val watchStart = if (includesWatch) { - exchange(startedPhone.toWatchStartCommand(request), WearRecordingOperation.START) - } else { - AppResult.success(Unit) - } - - if (state !== starting) return AppResult.success(Unit) - if (watchStart is AppResult.Failure) { - if (watchStart.error.wasWatchCommandSent()) { - record(watchStart.error) - state = PairedRecordingState.Recording(sessionId, includesWatch) - return AppResult.success(Unit) - } - - stopStartedRecording(sessionId, includesWatch, WearStopReason.SOURCE_FAILURE) - return record(watchStart) - } - - state = PairedRecordingState.Recording(sessionId, includesWatch) - return AppResult.success(Unit) - } - - suspend fun stop(reason: WearStopReason = WearStopReason.USER_REQUEST): AppResult { - val active = state.activeRecording() - ?: return phoneRecording.stopCurrent(reason).onFailure(::record) - state = PairedRecordingState.Stopping(active.sessionId, active.includesWatch) - return stopStartedRecording(active.sessionId, active.includesWatch, reason) - } - - suspend fun onAutomaticPhoneStop(sessionId: String, reason: WearStopReason): AppResult { - val active = state.activeRecording() - if (active?.sessionId != sessionId) return AppResult.success(Unit) - - val watchStop = stopWatch(sessionId, active.includesWatch, reason) - state = PairedRecordingState.Idle - return watchStop.onFailure(::record) - } - - suspend fun stopFromWatch(sessionId: String, reason: WearStopReason): AppResult { - if (state.activeRecording()?.sessionId != sessionId) return AppResult.success(Unit) - - val result = phoneRecording.stop(sessionId, reason) - state = PairedRecordingState.Idle - return result.onFailure(::record) - } - - private suspend fun stopStartedRecording( - sessionId: String, - includesWatch: Boolean, - reason: WearStopReason, - ): AppResult { - val phoneStop = phoneRecording.stop(sessionId, reason) - val watchStop = stopWatch(sessionId, includesWatch, reason) - state = PairedRecordingState.Idle - return listOf(phoneStop, watchStop) - .combineAppResults(AppErrorCode.RECORDING, "Stop phone and watch recording") - .onFailure(::record) - } - - private suspend fun stopWatch(sessionId: String, includesWatch: Boolean, reason: WearStopReason): AppResult = - if (includesWatch) { - exchange(WearCommand.StopRecording(sessionId, reason), WearRecordingOperation.STOP) - } else { - AppResult.success(Unit) - } - - private suspend fun exchange(command: WearCommand, expectedOperation: WearRecordingOperation): AppResult { - val sessionId = checkNotNull(command.recordingSessionId()) - watchResults.clear(sessionId, expectedOperation) - var lastSendError: AppError? = null - var commandWasSent = false - - repeat(ATTEMPT_COUNT) { retryCount -> - when (val sent = sendCommand(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, command)) { - is AppResult.Failure -> lastSendError = sent.error - - is AppResult.Success -> { - commandWasSent = true - lastSendError = null - val result = withTimeoutOrNull(RESULT_TIMEOUT_MILLIS / ATTEMPT_COUNT) { - watchResults.await(sessionId, expectedOperation) - } - if (result != null) return result.toAppResult(retryCount) - } - } - } - - if (!commandWasSent && lastSendError != null) return AppResult.failure(checkNotNull(lastSendError)) - - return AppResult.failure( - AppError( - code = AppErrorCode.TIMEOUT, - operation = expectedOperation.timeoutOperation(), - diagnosticMessage = "watch $expectedOperation result timed out", - context = mapOf( - "source" to "watch", - "sessionId" to sessionId, - "retryCount" to RETRY_COUNT.toString(), - "protocolVersion" to WearCommandCodec.PROTOCOL_VERSION.toString(), - "commandWasSent" to commandWasSent.toString(), - ), - isRetryable = true, - ), - ) - } - - private fun AppError.wasWatchCommandSent(): Boolean = - code == AppErrorCode.TIMEOUT && context["commandWasSent"] == true.toString() - - private fun conflict(operation: String): AppResult = - AppResult.failure(AppError(AppErrorCode.CONFLICT, operation)) - - private fun record(result: AppResult): AppResult = result.onFailure(::record) - - private fun record(error: AppError) { - diagnosticLogger.record(error.toDiagnosticEvent()) - } - - private companion object { - const val RESULT_TIMEOUT_MILLIS = 5_000L - const val RETRY_COUNT = 2 - const val ATTEMPT_COUNT = RETRY_COUNT + 1 - } -} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingModel.kt index 26a8e22..618d361 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingModel.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingModel.kt @@ -1,15 +1,9 @@ package com.tomasrepcik.sensorbox.domain.paired -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.domain.recording.RecordingSetup import com.tomasrepcik.sensorbox.domain.recording.StartedPhoneRecording import com.tomasrepcik.sensorbox.domain.recording.toWatchRecordingRequest import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOperation -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOutcome internal sealed interface PairedRecordingState { data object Idle : PairedRecordingState @@ -36,25 +30,3 @@ internal fun StartedPhoneRecording.toWatchStartCommand(request: RecordingSetup) sessionId = sessionId, request = toWatchRecordingRequest(request), ) - -internal fun WearCommand.RecordingResult.toAppResult(retryCount: Int): AppResult = - if (outcome == WearRecordingOutcome.SUCCEEDED) { - AppResult.success(Unit) - } else { - AppResult.failure( - AppError( - code = errorCode ?: AppErrorCode.UNKNOWN, - operation = errorOperation ?: "Handle watch $operation result", - diagnosticMessage = errorMessage ?: "Watch $operation failed", - context = errorContext + mapOf( - "source" to "watch", - "sessionId" to sessionId, - "retryCount" to retryCount.toString(), - "failureCount" to failureCount.toString(), - "protocolVersion" to WearCommandCodec.PROTOCOL_VERSION.toString(), - ), - ), - ) - } - -internal fun WearRecordingOperation.timeoutOperation(): String = "Await watch $name result" diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt index 4bc1be8..b5ad00f 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneRecordingSessionObserver.kt @@ -2,9 +2,10 @@ package com.tomasrepcik.sensorbox.domain.paired import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStopReason -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStopped -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.domain.recording.PhoneRecordingSessionControl +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStopReason +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStopped +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -18,7 +19,7 @@ import javax.inject.Singleton @Singleton class PhoneRecordingSessionObserver @Inject constructor( private val sessionStore: RecordingSessionStore, - private val pairedRecordingCoordinator: PairedRecordingCoordinator, + private val recording: PhoneRecordingSessionControl, private val diagnosticLogger: DiagnosticLogger, ) { private val started = AtomicBoolean(false) @@ -34,7 +35,7 @@ class PhoneRecordingSessionObserver @Inject constructor( private suspend fun onStopped(event: RecordingSessionStopped) { event.result.errorOrNull()?.let { error -> diagnosticLogger.record(error.toDiagnosticEvent()) } val reason = event.reason.toAutomaticWatchReason() ?: return - pairedRecordingCoordinator.onAutomaticPhoneStop(event.sessionId, reason) + recording.onAutomaticPhoneStop(event.sessionId, reason) } private fun RecordingSessionStopReason.toAutomaticWatchReason(): WearStopReason? = when (this) { diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWatchCommandHandler.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWatchCommandHandler.kt index ba04a1e..33a4138 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWatchCommandHandler.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/PhoneWatchCommandHandler.kt @@ -7,9 +7,11 @@ import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.appResult -import com.tomasrepcik.sensorbox.domain.sensors.WatchSensorCatalogStore +import com.tomasrepcik.sensorbox.domain.recording.PeerRecordingControl +import com.tomasrepcik.sensorbox.domain.sensors.ReceiveWatchSensorsUseCase import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingResultReceiver import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOperation @@ -19,21 +21,21 @@ import javax.inject.Inject class PhoneWatchCommandHandler @Inject constructor( @ApplicationContext private val context: Context, - private val watchSensorCatalog: WatchSensorCatalogStore, - private val recordingResults: WatchRecordingResultInbox, - private val pairedRecordingCoordinator: PairedRecordingCoordinator, + private val receiveWatchSensors: ReceiveWatchSensorsUseCase, + private val recordingResults: RecordingResultReceiver, + private val recording: PeerRecordingControl, private val sendCommand: SendWearCommandUseCase, ) { suspend fun handle(command: WearCommand): AppResult = when (command) { WearCommand.LaunchPhone -> launchPhone() is WearCommand.AvailableSensors -> { - watchSensorCatalog.update(command.sensors) + receiveWatchSensors.receive(command.sensors) AppResult.success(Unit) } is WearCommand.RecordingResult -> { - recordingResults.publish(command) + recordingResults.receive(command) AppResult.success(Unit) } @@ -55,7 +57,7 @@ class PhoneWatchCommandHandler @Inject constructor( } private suspend fun stopFromWatch(command: WearCommand.StopRecording): AppResult { - val stopResult = pairedRecordingCoordinator.stopFromWatch(command.sessionId, command.reason) + val stopResult = recording.stopFromWatch(command.sessionId, command.reason) val error = stopResult.errorOrNull() val result = WearCommand.RecordingResult( sessionId = command.sessionId, diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/WatchRecordingResultInbox.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/WatchRecordingResultInbox.kt deleted file mode 100644 index 32c2c14..0000000 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/paired/WatchRecordingResultInbox.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.tomasrepcik.sensorbox.domain.paired - -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOperation -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.first -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class WatchRecordingResultInbox @Inject constructor() { - private val results = ConcurrentHashMap() - private val updates = MutableSharedFlow(extraBufferCapacity = BUFFER_SIZE) - - fun publish(result: WearCommand.RecordingResult) { - results[result.key()] = result - updates.tryEmit(result) - } - - fun clear(sessionId: String, operation: WearRecordingOperation) { - results.remove(Key(sessionId, operation)) - } - - suspend fun await(sessionId: String, operation: WearRecordingOperation): WearCommand.RecordingResult { - val key = Key(sessionId, operation) - results[key]?.let { return it } - return updates.first { result -> result.key() == key } - } - - private fun WearCommand.RecordingResult.key() = Key(sessionId, operation) - - private data class Key(val sessionId: String, val operation: WearRecordingOperation) - - private companion object { - const val BUFFER_SIZE = 16 - } -} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/preview/DevicePreviewRepository.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/preview/DevicePreviewRepository.kt new file mode 100644 index 0000000..0587d20 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/preview/DevicePreviewRepository.kt @@ -0,0 +1,138 @@ +package com.tomasrepcik.sensorbox.domain.preview + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.location.Location +import android.os.PowerManager +import androidx.core.content.ContextCompat +import com.google.android.gms.location.LocationAvailability +import com.google.android.gms.location.LocationServices +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.error.appResult +import com.tomasrepcik.sensorbox.sensorservices.handlers.GPSHandler +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import javax.inject.Inject + +data class PreviewLocation( + val latitude: Double, + val longitude: Double, + val altitude: Double, + val accuracy: Float, + val speed: Float, + val bearing: Float, + val provider: String?, +) + +data class GpsPreviewData( + val hasPermission: Boolean, + val location: PreviewLocation? = null, + val isAvailable: Boolean? = null, +) + +data class SensorPreviewSample(val timestampNanos: Long, val values: List) + +data class SensorPreviewData(val isAvailable: Boolean, val samples: List = emptyList()) + +interface DevicePreviewRepository { + fun observeGps(intervalSeconds: Int, minimumDistanceMeters: Int): Flow> + + fun observeSensor(sensorType: Int): Flow> + + fun batteryOptimizationExemption(): AppResult +} + +class AndroidDevicePreviewRepository @Inject constructor(@ApplicationContext private val context: Context) : + DevicePreviewRepository { + override fun observeGps(intervalSeconds: Int, minimumDistanceMeters: Int): Flow> = + callbackFlow { + if (!hasLocationPermission()) { + trySend(AppResult.success(GpsPreviewData(hasPermission = false))) + close() + return@callbackFlow + } + + var current = GpsPreviewData(hasPermission = true) + trySend(AppResult.success(current)) + val handler = GPSHandler(LocationServices.getFusedLocationProviderClient(context)) + val callback = object : GPSHandler.OnLocationChangedCallback { + override fun onLocationChanged(location: Location?) = publishLocation(location) + + override fun onLastLocationSuccess(location: Location?) = publishLocation(location) + + override fun onAvailabilityChanged(locationAvailability: LocationAvailability?) { + current = current.copy(isAvailable = locationAvailability?.isLocationAvailable) + trySend(AppResult.success(current)) + } + + private fun publishLocation(location: Location?) { + if (location == null) return + current = current.copy(location = location.toPreviewLocation()) + trySend(AppResult.success(current)) + } + } + appResult(AppErrorCode.EXTERNAL_ACTION, "Start GPS preview") { + handler.addCallback(callback, intervalSeconds, minimumDistanceMeters) + }.onFailure { trySend(AppResult.failure(it)) } + awaitClose { handler.gpsOff() } + } + + override fun observeSensor(sensorType: Int): Flow> = callbackFlow { + val manager = context.getSystemService(SensorManager::class.java) + val sensor = manager?.getDefaultSensor(sensorType) + if (manager == null || sensor == null) { + trySend(AppResult.success(SensorPreviewData(isAvailable = false))) + close() + return@callbackFlow + } + + var samples = emptyList() + val listener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + samples = (samples + SensorPreviewSample(event.timestamp, event.values.toList())) + .takeLast(MAX_CHART_SAMPLES) + trySend(AppResult.success(SensorPreviewData(isAvailable = true, samples = samples))) + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + } + val registered = manager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_UI) + trySend(AppResult.success(SensorPreviewData(isAvailable = registered))) + awaitClose { manager.unregisterListener(listener) } + } + + override fun batteryOptimizationExemption(): AppResult = appResult( + AppErrorCode.EXTERNAL_ACTION, + "Read battery optimization exemption", + ) { + context.getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(context.packageName) + } + + private fun hasLocationPermission(): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + + private fun Location.toPreviewLocation() = PreviewLocation( + latitude = latitude, + longitude = longitude, + altitude = altitude, + accuracy = accuracy, + speed = speed, + bearing = bearing, + provider = provider, + ) + + private companion object { + const val MAX_CHART_SAMPLES = 90 + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingArchiveRepository.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingArchiveRepository.kt index bd58020..24c6801 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingArchiveRepository.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingArchiveRepository.kt @@ -1,16 +1,20 @@ package com.tomasrepcik.sensorbox.domain.recording -import android.content.Intent import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.storage.DocumentStorage import javax.inject.Inject +sealed interface RecordingArchiveSelection { + data class Selected(val uri: String, val grantFlags: Int) : RecordingArchiveSelection + data object Cancelled : RecordingArchiveSelection +} + interface RecordingArchiveRepository { fun isSelected(): AppResult fun path(): AppResult - fun select(resultIntent: Intent): AppResult + fun select(selection: RecordingArchiveSelection.Selected): AppResult } class DocumentRecordingArchiveRepository @Inject constructor(private val storage: DocumentStorage) : @@ -19,5 +23,6 @@ class DocumentRecordingArchiveRepository @Inject constructor(private val storage override fun path(): AppResult = storage.displayPath() - override fun select(resultIntent: Intent): AppResult = storage.persistRootAccess(resultIntent) + override fun select(selection: RecordingArchiveSelection.Selected): AppResult = + storage.persistRootAccess(selection.uri, selection.grantFlags) } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingControlUseCase.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingControlUseCase.kt index 38e1a29..b67e652 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingControlUseCase.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/recording/RecordingControlUseCase.kt @@ -1,10 +1,23 @@ package com.tomasrepcik.sensorbox.domain.recording +import com.tomasrepcik.sensorbox.core.error.AppError +import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.error.combineAppResults +import com.tomasrepcik.sensorbox.core.error.toDiagnosticEvent import com.tomasrepcik.sensorbox.core.time.EpochClock -import com.tomasrepcik.sensorbox.domain.paired.PairedRecordingCoordinator +import com.tomasrepcik.sensorbox.domain.paired.PairedRecordingState +import com.tomasrepcik.sensorbox.domain.paired.activeRecording +import com.tomasrepcik.sensorbox.domain.paired.includesWatchRecording +import com.tomasrepcik.sensorbox.domain.paired.toWatchStartCommand +import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingCommandSender +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason +import com.tomasrepcik.sensorbox.wearoslib.protocol.wasRecordingCommandSent +import java.util.UUID import javax.inject.Inject +import javax.inject.Singleton interface RecordingControlUseCase { suspend fun start(request: RecordingSetup): AppResult @@ -14,14 +27,117 @@ interface RecordingControlUseCase { fun annotate(text: String): AppResult } +interface PhoneRecordingSessionControl { + suspend fun onAutomaticPhoneStop(sessionId: String, reason: WearStopReason): AppResult +} + +interface PeerRecordingControl { + suspend fun stopFromWatch(sessionId: String, reason: WearStopReason): AppResult +} + +@Singleton class DefaultRecordingControlUseCase @Inject constructor( - private val pairedRecordingCoordinator: PairedRecordingCoordinator, - private val localController: PhoneRecordingController, + private val phoneRecording: PhoneRecordingController, + private val watchCommands: RecordingCommandSender, + private val diagnosticLogger: DiagnosticLogger, private val clock: EpochClock, -) : RecordingControlUseCase { - override suspend fun start(request: RecordingSetup): AppResult = pairedRecordingCoordinator.start(request) +) : RecordingControlUseCase, + PhoneRecordingSessionControl, + PeerRecordingControl { + private var state: PairedRecordingState = PairedRecordingState.Idle + + @Suppress("ReturnCount") + override suspend fun start(request: RecordingSetup): AppResult { + if (state !is PairedRecordingState.Idle) return conflict("Start recording") + + val sessionId = UUID.randomUUID().toString() + val includesWatch = request.includesWatchRecording() + val starting = PairedRecordingState.Starting(sessionId, includesWatch) + state = starting + + val startedPhone = when (val phoneStart = phoneRecording.start(sessionId, request)) { + is AppResult.Failure -> { + state = PairedRecordingState.Idle + return record(phoneStart) + } + + is AppResult.Success -> phoneStart.value + } + + val watchStart = if (includesWatch) { + watchCommands.send(startedPhone.toWatchStartCommand(request)) + } else { + AppResult.success(Unit) + } + + if (state !== starting) return AppResult.success(Unit) + if (watchStart is AppResult.Failure) { + if (watchStart.error.wasRecordingCommandSent()) { + record(watchStart.error) + state = PairedRecordingState.Recording(sessionId, includesWatch) + return AppResult.success(Unit) + } + + stopStartedRecording(sessionId, includesWatch, WearStopReason.SOURCE_FAILURE) + return record(watchStart) + } + + state = PairedRecordingState.Recording(sessionId, includesWatch) + return AppResult.success(Unit) + } + + override suspend fun stop(): AppResult { + val active = state.activeRecording() + ?: return phoneRecording.stopCurrent(WearStopReason.USER_REQUEST).onFailure(::record) + state = PairedRecordingState.Stopping(active.sessionId, active.includesWatch) + return stopStartedRecording(active.sessionId, active.includesWatch, WearStopReason.USER_REQUEST) + } + + override fun annotate(text: String): AppResult = phoneRecording.annotate(text, clock.nowMillis()) + + override suspend fun onAutomaticPhoneStop(sessionId: String, reason: WearStopReason): AppResult { + val active = state.activeRecording() + if (active?.sessionId != sessionId) return AppResult.success(Unit) + + val watchStop = stopWatch(sessionId, active.includesWatch, reason) + state = PairedRecordingState.Idle + return watchStop.onFailure(::record) + } + + override suspend fun stopFromWatch(sessionId: String, reason: WearStopReason): AppResult { + if (state.activeRecording()?.sessionId != sessionId) return AppResult.success(Unit) + + val result = phoneRecording.stop(sessionId, reason) + state = PairedRecordingState.Idle + return result.onFailure(::record) + } + + private suspend fun stopStartedRecording( + sessionId: String, + includesWatch: Boolean, + reason: WearStopReason, + ): AppResult { + val phoneStop = phoneRecording.stop(sessionId, reason) + val watchStop = stopWatch(sessionId, includesWatch, reason) + state = PairedRecordingState.Idle + return listOf(phoneStop, watchStop) + .combineAppResults(AppErrorCode.RECORDING, "Stop phone and watch recording") + .onFailure(::record) + } + + private suspend fun stopWatch(sessionId: String, includesWatch: Boolean, reason: WearStopReason): AppResult = + if (includesWatch) { + watchCommands.send(WearCommand.StopRecording(sessionId, reason)) + } else { + AppResult.success(Unit) + } + + private fun conflict(operation: String): AppResult = + AppResult.failure(AppError(AppErrorCode.CONFLICT, operation)) - override suspend fun stop(): AppResult = pairedRecordingCoordinator.stop(WearStopReason.USER_REQUEST) + private fun record(result: AppResult): AppResult = result.onFailure(::record) - override fun annotate(text: String): AppResult = localController.annotate(text, clock.nowMillis()) + private fun record(error: AppError) { + diagnosticLogger.record(error.toDiagnosticEvent()) + } } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/RecordingSourceAvailability.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/RecordingSourceAvailability.kt new file mode 100644 index 0000000..6911ae8 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/RecordingSourceAvailability.kt @@ -0,0 +1,84 @@ +package com.tomasrepcik.sensorbox.domain.sensors + +import com.tomasrepcik.sensorbox.core.error.AppError +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.connectivity.ObserveWearCapabilityUseCase +import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnection +import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +data class AvailableRecordingSources( + val phoneSensors: List, + val watchSensors: List = emptyList(), + val isWatchConnected: Boolean = false, +) + +interface AvailableRecordingSourcesUseCase { + val current: AvailableRecordingSources + + fun observe(): Flow +} + +fun interface ReceiveWatchSensorsUseCase { + fun receive(sensors: List) +} + +@Singleton +class RecordingSourceAvailability @Inject constructor( + phoneSensors: AvailableSensorsUseCase, + private val observeWatchCapability: ObserveWearCapabilityUseCase, + private val sendWatchCommand: SendWearCommandUseCase, +) : AvailableRecordingSourcesUseCase, + ReceiveWatchSensorsUseCase { + private val mutableSources = MutableStateFlow( + AvailableRecordingSources(phoneSensors = phoneSensors()), + ) + + override val current: AvailableRecordingSources + get() = mutableSources.value + + override fun observe(): Flow = channelFlow { + launch { mutableSources.collect { sources -> send(sources) } } + observeWatchCapability(WEAR_APP_CAPABILITY) + .catch { error -> + AppError.from(AppErrorCode.CONNECTIVITY, "Observe Wear connection", error) + emit(WearConnection.Disconnected) + } + .collect(::onWatchConnectionChanged) + } + + override fun receive(sensors: List) { + mutableSources.value = current.copy( + watchSensors = sensors + .distinctBy(WearSensorInfo::type) + .sortedBy(WearSensorInfo::name) + .map(WearSensorInfo::toSensorDescriptor), + isWatchConnected = true, + ) + } + + private suspend fun onWatchConnectionChanged(connection: WearConnection) { + if (connection is WearConnection.Connected) { + sendWatchCommand( + WEAR_APP_CAPABILITY, + WEAR_MESSAGE_PATH, + WearCommand.RequestAvailableSensors, + ) + } else { + mutableSources.value = current.copy( + watchSensors = emptyList(), + isWatchConnected = false, + ) + } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorCatalogStore.kt b/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorCatalogStore.kt deleted file mode 100644 index caa3da1..0000000 --- a/app/src/main/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorCatalogStore.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.tomasrepcik.sensorbox.domain.sensors - -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class WatchSensorCatalogStore @Inject constructor() { - private val mutableSensors = MutableStateFlow>(emptyList()) - private val mutableAvailable = MutableStateFlow(false) - - val sensors: StateFlow> = mutableSensors.asStateFlow() - val isAvailable: StateFlow = mutableAvailable.asStateFlow() - - fun update(sensors: List) { - mutableSensors.value = sensors.distinctBy(WearSensorInfo::type).sortedBy(WearSensorInfo::name) - mutableAvailable.value = true - } - - fun clear() { - mutableSensors.value = emptyList() - mutableAvailable.value = false - } -} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutDialogBody.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutDialogBody.kt new file mode 100644 index 0000000..b09a53a --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutDialogBody.kt @@ -0,0 +1,30 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.BuildConfig +import com.tomasrepcik.sensorbox.R + +@Composable +internal fun AboutDialogBody(onPrivacy: () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + stringResource(R.string.about_version, BuildConfig.VERSION_NAME), + color = MaterialTheme.colorScheme.primary, + ) + Text( + stringResource(R.string.about_app_description), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton(onClick = onPrivacy, contentPadding = PaddingValues(0.dp)) { + Text(stringResource(R.string.about_privacy_policy)) + } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutScreen.kt index 579fbd1..3b7e154 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutScreen.kt @@ -1,38 +1,16 @@ package com.tomasrepcik.sensorbox.presentation.main -import android.content.res.Resources import androidx.compose.foundation.Image -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 -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.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.tomasrepcik.sensorbox.BuildConfig import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.appResult @Composable fun AboutDialog(onDismiss: () -> Unit, onPrivacy: () -> Unit, onLicenses: () -> Unit) { @@ -59,86 +37,3 @@ fun AboutDialog(onDismiss: () -> Unit, onPrivacy: () -> Unit, onLicenses: () -> }, ) } - -@Composable -private fun AboutDialogBody(onPrivacy: () -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text( - stringResource(R.string.about_version, BuildConfig.VERSION_NAME), - color = MaterialTheme.colorScheme.primary, - ) - Text( - stringResource(R.string.about_app_description), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - TextButton(onClick = onPrivacy, contentPadding = PaddingValues(0.dp)) { - Text(stringResource(R.string.about_privacy_policy)) - } - } -} - -@Composable -fun OpenSourceLicensesScreen(onBack: () -> Unit, modifier: Modifier = Modifier) { - val resources = LocalContext.current.resources - val licenses = remember(resources) { - appResult(AppErrorCode.STORAGE, "Load open source licenses") { - loadOpenSourceLicenses(resources) - }.getOrDefault(emptyList()) - } - var selectedLicense by remember { mutableStateOf(null) } - - SensorBoxBackScreen( - title = stringResource(R.string.about_licenses), - onBack = onBack, - modifier = modifier, - ) { - items(licenses, key = OpenSourceLicense::name) { license -> - Surface( - modifier = Modifier.fillMaxWidth().clickable { selectedLicense = license }, - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.large, - ) { - Text( - text = license.name, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - style = MaterialTheme.typography.titleMedium, - ) - } - } - } - - selectedLicense?.let { license -> - AlertDialog( - onDismissRequest = { selectedLicense = null }, - title = { Text(license.name) }, - text = { - Box(Modifier.heightIn(max = 480.dp).verticalScroll(rememberScrollState())) { - Text(license.text, style = MaterialTheme.typography.bodySmall) - } - }, - confirmButton = { - TextButton(onClick = { selectedLicense = null }) { - Text(stringResource(R.string.close)) - } - }, - ) - } -} - -private data class OpenSourceLicense(val name: String, val text: String) - -private fun loadOpenSourceLicenses(resources: Resources): List { - val licenseBytes = resources.openRawResource(R.raw.third_party_licenses).use { it.readBytes() } - return resources.openRawResource(R.raw.third_party_license_metadata).bufferedReader().useLines { lines -> - lines.mapNotNull { line -> - val location = line.substringBefore(' ') - val name = line.substringAfter(' ', missingDelimiterValue = "").trim() - val offset = location.substringBefore(':').toIntOrNull() - val length = location.substringAfter(':', missingDelimiterValue = "").toIntOrNull() - if (name.isEmpty() || offset == null || length == null || offset + length > licenseBytes.size) { - return@mapNotNull null - } - OpenSourceLicense(name, licenseBytes.decodeToString(offset, offset + length).trim()) - }.distinctBy(OpenSourceLicense::name).sortedBy { it.name.lowercase() }.toList() - } -} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutSetting.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutSetting.kt new file mode 100644 index 0000000..ae1cc78 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AboutSetting.kt @@ -0,0 +1,33 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import com.tomasrepcik.sensorbox.R + +@Composable +internal fun AboutSetting(onIntent: (SettingsIntent) -> Unit) { + var showAboutDialog by rememberSaveable { mutableStateOf(false) } + SettingsControlRow( + title = stringResource(R.string.menu_about), + description = stringResource(R.string.about_summary), + showDivider = false, + showChevron = true, + ) { showAboutDialog = true } + if (showAboutDialog) { + AboutDialog( + onDismiss = { showAboutDialog = false }, + onPrivacy = { + showAboutDialog = false + onIntent(SettingsIntent.Navigate(MainRoute.PRIVACY)) + }, + onLicenses = { + showAboutDialog = false + onIntent(SettingsIntent.Navigate(MainRoute.LICENSES)) + }, + ) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ActiveRecordingComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ActiveRecordingComponents.kt new file mode 100644 index 0000000..eff4549 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ActiveRecordingComponents.kt @@ -0,0 +1,250 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import android.content.res.Resources +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +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.Row +import androidx.compose.foundation.layout.Spacer +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.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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 +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.core.format.ValueFormats +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import com.tomasrepcik.sensorbox.ui.theme.SensorBoxRecording + +@Composable +internal fun AnnotationEditor(onIntent: (RecordingIntent) -> Unit) { + var annotation by remember { mutableStateOf("") } + 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, + ) + AddAnnotationButton( + enabled = annotation.isNotBlank(), + onClick = { + annotation.trim().takeIf(String::isNotEmpty)?.let { + onIntent(RecordingIntent.AddAnnotation(it)) + annotation = "" + } + }, + ) + } + SensorBoxSettingsDivider() +} + +@Composable +private fun AddAnnotationButton(enabled: Boolean, onClick: () -> Unit) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + SensorBoxPrimaryButton( + label = stringResource(R.string.add_annotation), + onClick = onClick, + modifier = Modifier.widthIn(min = 152.dp, max = 196.dp), + enabled = enabled, + ) + } +} + +@Composable +internal fun RecordingHeader(modifier: Modifier = Modifier) { + Row( + modifier = modifier.fillMaxWidth().height(64.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + RecordingIndicator() + Spacer(Modifier.size(8.dp)) + Text( + stringResource(R.string.recording), + color = SensorBoxRecording, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Composable +private fun RecordingIndicator() { + val transition = rememberInfiniteTransition(label = "recording pulse") + val pulseAlpha by transition.animateFloat( + initialValue = RECORDING_DIM_ALPHA, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(RECORDING_PULSE_MILLIS, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "recording alpha", + ) + val pulseScale by transition.animateFloat( + initialValue = RECORDING_MIN_SCALE, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(RECORDING_PULSE_MILLIS, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "recording scale", + ) + Box( + Modifier + .size(12.dp) + .graphicsLayer { + alpha = pulseAlpha + scaleX = pulseScale + scaleY = pulseScale + } + .clip(CircleShape) + .background(SensorBoxRecording), + ) +} + +@Composable +internal fun RecordingTimer(elapsedSeconds: Long, folderName: String) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(stringResource(R.string.elapsed_time), color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) + Text(ValueFormats.elapsedSeconds(elapsedSeconds), style = MaterialTheme.typography.displayLarge) + Text(folderName, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + } +} + +@Composable +internal fun RecordingSummary(state: RecordingState, session: RecordingSessionState.Running) { + var expanded by remember { mutableStateOf(false) } + val sources = recordingSourceNames(state, session, LocalContext.current.resources) + Column(Modifier.fillMaxWidth()) { + SourceListToggle(sourceCount = sources.size, expanded = expanded, onClick = { expanded = !expanded }) + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + RecordingSourceList(sources) + } + SensorBoxSettingsDivider() + } +} + +@Composable +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 = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + 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), + modifier = Modifier.size(28.dp).rotate(arrowRotation), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun RecordingSourceList(sources: List) { + Column { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + sources.forEach { source -> + Text( + source, + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + style = MaterialTheme.typography.bodyLarge, + ) + } + } +} + +private fun recordingSourceNames( + state: RecordingState, + session: RecordingSessionState.Running, + resources: Resources, +): List = buildList { + session.sensorIds.forEach { sensorId -> + val sensorName = state.sensors.firstOrNull { sensor -> sensor.type == sensorId }?.name + ?: resources.getString(R.string.sensor_number, sensorId) + add(sensorName) + } + if (session.includesGps) add(resources.getString(R.string.gps)) +} + +@Composable +internal fun ActiveRecordingTopBar(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.StopRecording) }, + modifier = Modifier.widthIn(min = 176.dp, max = 240.dp), + ) + } +} + +private const val RECORDING_PULSE_MILLIS = 850 +private const val RECORDING_DIM_ALPHA = 0.28f +private const val RECORDING_MIN_SCALE = 0.78f diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ActiveRecordingScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ActiveRecordingScreen.kt index e50dba8..22a485b 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ActiveRecordingScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ActiveRecordingScreen.kt @@ -1,66 +1,23 @@ package com.tomasrepcik.sensorbox.presentation.main -import android.content.res.Resources -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.background -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.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.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -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 import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.core.format.ValueFormats -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionState -import com.tomasrepcik.sensorbox.ui.theme.SensorBoxRecording +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState @Composable fun ActiveRecordingScreen(state: RecordingState, onIntent: (RecordingIntent) -> Unit, modifier: Modifier = Modifier) { val session = state.session as? RecordingSessionState.Running ?: return - Column( - modifier = modifier.fillMaxSize(), - ) { + Column(modifier.fillMaxSize()) { RecordingHeader(Modifier.padding(start = 24.dp, top = 8.dp, end = 24.dp)) Column( modifier = Modifier @@ -78,193 +35,3 @@ fun ActiveRecordingScreen(state: RecordingState, onIntent: (RecordingIntent) -> ActiveRecordingTopBar(onIntent) } } - -@Composable -private fun AnnotationEditor(onIntent: (RecordingIntent) -> Unit) { - var annotation by remember { mutableStateOf("") } - 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(modifier: Modifier = Modifier) { - Row( - modifier = modifier.fillMaxWidth().height(64.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - RecordingIndicator() - Spacer(Modifier.size(8.dp)) - Text( - stringResource(R.string.recording), - color = SensorBoxRecording, - style = MaterialTheme.typography.labelLarge, - ) - } -} - -@Composable -private fun RecordingIndicator() { - val transition = rememberInfiniteTransition(label = "recording pulse") - val pulseAlpha by transition.animateFloat( - initialValue = RECORDING_DIM_ALPHA, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(RECORDING_PULSE_MILLIS, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "recording alpha", - ) - val pulseScale by transition.animateFloat( - initialValue = RECORDING_MIN_SCALE, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(RECORDING_PULSE_MILLIS, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "recording scale", - ) - Box( - Modifier - .size(12.dp) - .graphicsLayer { - alpha = pulseAlpha - scaleX = pulseScale - scaleY = pulseScale - } - .clip(CircleShape) - .background(SensorBoxRecording), - ) -} - -@Composable -private fun RecordingTimer(elapsedSeconds: Long, folderName: String) { - Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(stringResource(R.string.elapsed_time), color = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.height(4.dp)) - Text(ValueFormats.elapsedSeconds(elapsedSeconds), style = MaterialTheme.typography.displayLarge) - Text(folderName, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) - } -} - -@Composable -private fun RecordingSummary(state: RecordingState, session: RecordingSessionState.Running) { - var expanded by remember { mutableStateOf(false) } - val sources = recordingSourceNames(state, session, LocalContext.current.resources) - Column(Modifier.fillMaxWidth()) { - SourceListToggle(sourceCount = sources.size, expanded = expanded, onClick = { expanded = !expanded }) - AnimatedVisibility( - visible = expanded, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - RecordingSourceList(sources) - } - SensorBoxSettingsDivider() - } -} - -@Composable -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 = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - 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), - modifier = Modifier.size(28.dp).rotate(arrowRotation), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -@Composable -private fun RecordingSourceList(sources: List) { - Column { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - sources.forEach { source -> - Text( - source, - modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), - style = MaterialTheme.typography.bodyLarge, - ) - } - } -} - -private fun recordingSourceNames( - state: RecordingState, - session: RecordingSessionState.Running, - resources: Resources, -): List = buildList { - session.sensorIds.forEach { sensorId -> - val sensorName = state.sensors.firstOrNull { sensor -> - sensor.type == sensorId - }?.name ?: resources.getString(R.string.sensor_number, sensorId) - add(sensorName) - } - if (session.includesGps) add(resources.getString(R.string.gps)) -} - -@Composable -private fun ActiveRecordingTopBar(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.StopRecording) }, - modifier = Modifier.widthIn(min = 176.dp, max = 240.dp), - ) - } -} - -private const val RECORDING_PULSE_MILLIS = 850 -private const val RECORDING_DIM_ALPHA = 0.28f -private const val RECORDING_MIN_SCALE = 0.78f diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AppErrorScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AppErrorScreen.kt new file mode 100644 index 0000000..4a8dc0e --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/AppErrorScreen.kt @@ -0,0 +1,64 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.core.error.AppErrorCode + +@Composable +internal fun AppErrorScreen(errorCode: AppErrorCode, onDismiss: () -> Unit) { + FullScreen { modifier -> + Column( + modifier = modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.app_error_title), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(errorCode.messageResource()), + modifier = Modifier.padding(top = 16.dp, bottom = 32.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + SensorBoxPrimaryButton( + label = stringResource(R.string.close), + onClick = onDismiss, + ) + } + } +} + +private fun AppErrorCode.messageResource(): Int = when (this) { + AppErrorCode.CONNECTIVITY -> R.string.app_error_connectivity + + AppErrorCode.PERMISSION -> R.string.app_error_permission + + AppErrorCode.PREFERENCES -> R.string.app_error_preferences + + AppErrorCode.RECORDING -> R.string.app_error_recording + + AppErrorCode.STORAGE -> R.string.app_error_storage + + AppErrorCode.EXTERNAL_ACTION -> R.string.app_error_external_action + + AppErrorCode.TIMEOUT -> R.string.app_error_timeout + + AppErrorCode.VALIDATION, + AppErrorCode.CONFLICT, + AppErrorCode.UNKNOWN, + -> R.string.app_error_unknown +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/DiagnosticsSetting.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/DiagnosticsSetting.kt new file mode 100644 index 0000000..b18cc19 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/DiagnosticsSetting.kt @@ -0,0 +1,82 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R + +@Composable +internal fun DiagnosticsSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { + var confirmClear by rememberSaveable { mutableStateOf(false) } + DiagnosticsContent(state, onIntent, onClear = { confirmClear = true }) + if (confirmClear) { + ClearDiagnosticsDialog( + onConfirm = { + confirmClear = false + onIntent(SettingsIntent.ClearDiagnostics) + }, + onDismiss = { confirmClear = false }, + ) + } +} + +@Composable +private fun DiagnosticsContent(state: SettingsState, onIntent: (SettingsIntent) -> Unit, onClear: () -> Unit) { + when { + !state.diagnosticsLoaded -> DiagnosticsStatusText(R.string.diagnostics_loading) + + state.diagnosticsText.isNullOrBlank() -> DiagnosticsStatusText(R.string.diagnostics_empty) + + else -> { + SettingsControlRow( + title = stringResource(R.string.diagnostics_view), + description = stringResource(R.string.diagnostics_view_summary), + ) { onIntent(SettingsIntent.Navigate(MainRoute.DIAGNOSTICS)) } + SettingsControlRow( + title = stringResource(R.string.diagnostics_share), + description = stringResource(R.string.diagnostics_share_summary), + ) { onIntent(SettingsIntent.ShareDiagnosticsFile) } + SettingsControlRow( + title = stringResource(R.string.diagnostics_clear), + description = stringResource(R.string.diagnostics_clear_summary), + showDivider = false, + onClick = onClear, + ) + } + } +} + +@Composable +private fun DiagnosticsStatusText(textResource: Int) { + Text( + stringResource(textResource), + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun ClearDiagnosticsDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.diagnostics_clear)) }, + text = { Text(stringResource(R.string.diagnostics_clear_confirmation)) }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(R.string.diagnostics_clear)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsDetails.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsDetails.kt new file mode 100644 index 0000000..d8a8804 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsDetails.kt @@ -0,0 +1,92 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.domain.preview.GpsPreviewData + +@Composable +internal fun GpsDetails(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + DisposableEffect(Unit) { + onIntent(RecordingIntent.StartGpsPreview) + onDispose { onIntent(RecordingIntent.StopPreview) } + } + val details = state.gpsPreview + val unavailableValue = stringResource(if (details.hasPermission) R.string.waiting else R.string.unavailable) + Column(Modifier.fillMaxWidth()) { + GpsPermissionStatus(details.hasPermission) + GpsPositionRows(details, unavailableValue) + GpsRecordingRows(state) + if (!details.hasPermission) { + GpsPermissionButton { onIntent(RecordingIntent.RequestLocationPreviewPermission) } + } + } +} + +@Composable +private fun GpsPositionRows(details: GpsPreviewData, unavailableValue: String) { + ParameterRow(stringResource(R.string.detail_latitude), details.location?.latitude?.toString() ?: unavailableValue) + ParameterRow(stringResource(R.string.detail_longitude), details.location?.longitude?.toString() ?: unavailableValue) + 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)) +} + +@Composable +private fun GpsRecordingRows(state: RecordingState) { + val seconds = state.preferences.recording.gpsIntervalSeconds + val meters = state.preferences.recording.gpsMinDistanceMeters + ParameterRow( + stringResource(R.string.detail_update_interval), + pluralStringResource(R.plurals.seconds_count, seconds, seconds), + ) + ParameterRow( + stringResource(R.string.detail_minimum_distance), + pluralStringResource(R.plurals.meters_count, meters, meters), + showDivider = false, + ) +} + +@Composable +private fun GpsPermissionStatus(hasPermission: Boolean) { + ParameterRow( + stringResource(R.string.location_permission), + stringResource(if (hasPermission) R.string.granted else R.string.required), + ) +} + +@Composable +private fun GpsPermissionButton(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), + onClick = onRequest, + modifier = Modifier.fillMaxWidth(), + ) +} + +private fun localizedValue(value: Number?, unavailableValue: String): String = + value?.let(::formatDecimal) ?: unavailableValue + +@Composable +private fun locationAvailabilityLabel(isAvailable: Boolean?): String = when (isAvailable) { + true -> stringResource(R.string.yes) + false -> stringResource(R.string.no) + null -> stringResource(R.string.unknown) +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsPreview.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsPreview.kt new file mode 100644 index 0000000..9384664 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsPreview.kt @@ -0,0 +1,78 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R + +@Composable +internal fun GpsPreview(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + DisposableEffect(Unit) { + onIntent(RecordingIntent.StartGpsPreview) + onDispose { onIntent(RecordingIntent.StopPreview) } + } + val details = state.gpsPreview + val unavailableValue = stringResource(if (details.hasPermission) R.string.waiting else R.string.unavailable) + GpsPreviewPanel(details, unavailableValue) { onIntent(RecordingIntent.RequestLocationPreviewPermission) } +} + +@Composable +private fun GpsPreviewPanel( + details: com.tomasrepcik.sensorbox.domain.preview.GpsPreviewData, + unavailableValue: String, + onRequestPermission: () -> Unit, +) { + Column(Modifier.fillMaxWidth()) { + GpsPreviewValues(details, unavailableValue) + if (!details.hasPermission) GpsPreviewPermissionRequest(onRequestPermission) + } +} + +@Composable +private fun GpsPreviewValues( + details: com.tomasrepcik.sensorbox.domain.preview.GpsPreviewData, + unavailableValue: String, +) { + 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, + ) +} + +@Composable +private fun GpsPreviewPermissionRequest(onRequestPermission: () -> 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), + onClick = onRequestPermission, + modifier = Modifier.fillMaxWidth(), + ) +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsSettings.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsSettings.kt new file mode 100644 index 0000000..acca214 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/GpsSettings.kt @@ -0,0 +1,29 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import com.tomasrepcik.sensorbox.R + +@Composable +internal fun GpsSettings(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { + val intervalSeconds = state.preferences.recording.gpsIntervalSeconds + val distanceMeters = state.preferences.recording.gpsMinDistanceMeters + Column { + NumberPickerSetting( + stringResource(R.string.gps_interval), + intervalSeconds, + pluralStringResource(R.plurals.seconds_count, intervalSeconds, intervalSeconds), + 1, + 3_600, + ) { onIntent(SettingsIntent.SetGpsInterval(it)) } + NumberPickerSetting( + stringResource(R.string.gps_minimum_distance), + distanceMeters, + pluralStringResource(R.plurals.meters_count, distanceMeters, distanceMeters), + 0, + 10_000, + ) { onIntent(SettingsIntent.SetGpsDistance(it)) } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/HardwareSensorDetails.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/HardwareSensorDetails.kt new file mode 100644 index 0000000..b2649a7 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/HardwareSensorDetails.kt @@ -0,0 +1,90 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import android.hardware.Sensor +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.core.format.ValueFormats +import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor +import com.tomasrepcik.sensorbox.domain.sensors.SensorReportingMode + +@Composable +internal fun HardwareSensorDetails(sensor: SensorDescriptor) { + Column(Modifier.fillMaxWidth()) { + val unit = sensorUnit(sensor.type) + SensorIdentityDetails(sensor, unit) + SensorTimingDetails(sensor) + SensorBehaviorDetails(sensor) + } +} + +@Composable +private fun SensorIdentityDetails(sensor: SensorDescriptor, unit: String) { + val type = sensor.stringType.ifBlank { stringResource(R.string.detail_sensor_type_value, sensor.type) } + 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_android_sensor_type), type) +} + +@Composable +private fun SensorTimingDetails(sensor: SensorDescriptor) { + 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), + ) +} + +@Composable +private fun SensorBehaviorDetails(sensor: SensorDescriptor) { + 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, + ) +} + +@Composable +private fun reportingModeLabel(mode: SensorReportingMode): String = when (mode) { + SensorReportingMode.CONTINUOUS -> stringResource(R.string.reporting_continuous) + SensorReportingMode.ON_CHANGE -> stringResource(R.string.reporting_on_change) + SensorReportingMode.ONE_SHOT -> stringResource(R.string.reporting_one_shot) + SensorReportingMode.SPECIAL_TRIGGER -> stringResource(R.string.reporting_special_trigger) + SensorReportingMode.UNKNOWN -> stringResource(R.string.unknown) +} + +@Composable +internal fun sensorUnit(type: Int): String = when (type) { + Sensor.TYPE_ACCELEROMETER, Sensor.TYPE_GRAVITY, Sensor.TYPE_LINEAR_ACCELERATION -> + stringResource(R.string.unit_acceleration) + + Sensor.TYPE_GYROSCOPE -> stringResource(R.string.unit_angular_velocity) + + Sensor.TYPE_RELATIVE_HUMIDITY -> stringResource(R.string.unit_percent) + + Sensor.TYPE_MAGNETIC_FIELD -> stringResource(R.string.unit_magnetic_field) + + Sensor.TYPE_PROXIMITY -> stringResource(R.string.unit_centimeters) + + Sensor.TYPE_PRESSURE -> stringResource(R.string.unit_pressure) + + Sensor.TYPE_LIGHT -> stringResource(R.string.unit_lux) + + Sensor.TYPE_AMBIENT_TEMPERATURE -> stringResource(R.string.unit_temperature) + + Sensor.TYPE_STEP_COUNTER, Sensor.TYPE_STEP_DETECTOR -> stringResource(R.string.unit_steps) + + else -> stringResource(R.string.unit_none) +} + +internal fun formatDecimal(value: Number): String = ValueFormats.decimal(value) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/HardwareSensorPreview.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/HardwareSensorPreview.kt new file mode 100644 index 0000000..8657b56 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/HardwareSensorPreview.kt @@ -0,0 +1,80 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import android.hardware.Sensor +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.res.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.domain.preview.SensorPreviewData +import com.tomasrepcik.sensorbox.domain.preview.SensorPreviewSample +import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor + +@Composable +internal fun HardwareSensorPreview(sensor: SensorDescriptor, preview: SensorPreviewData) { + val latestValues = preview.samples.lastOrNull()?.values + val unit = sensorUnit(sensor.type) + 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) + } + } +} + +@Composable +private fun StepCounterPreview(value: Float?) { + if (value == null) { + Text(stringResource(R.string.waiting_sensor_data), color = MaterialTheme.colorScheme.onSurfaceVariant) + return + } + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(value.toLong().toString(), style = MaterialTheme.typography.displayMedium) + Text(stringResource(R.string.unit_steps), color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun SensorValues(values: List?, unit: String) { + if (values == null) { + Text(stringResource(R.string.waiting_sensor_data), color = MaterialTheme.colorScheme.onSurfaceVariant) + return + } + values.forEachIndexed { index, value -> + PreviewValueRow( + label = stringResource(R.string.sensor_value_with_unit, axisLabel(index, values.size), unit), + value = formatDecimal(value), + showDivider = index != values.lastIndex, + ) + } +} + +@Composable +internal fun axisLabel(index: Int, axisCount: Int): String = when { + axisCount == 1 -> stringResource(R.string.value) + index == 0 -> stringResource(R.string.axis_x) + index == 1 -> stringResource(R.string.axis_y) + index == 2 -> stringResource(R.string.axis_z) + index == 3 -> stringResource(R.string.axis_w) + else -> stringResource(R.string.value_number, index + 1) +} + +internal typealias TimedSensorSample = SensorPreviewSample diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/LiveSensorChart.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/LiveSensorChart.kt new file mode 100644 index 0000000..974a523 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/LiveSensorChart.kt @@ -0,0 +1,234 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import android.graphics.Paint +import androidx.compose.foundation.Canvas +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.core.format.ValueFormats +import kotlin.math.abs +import kotlin.math.max + +@Composable +internal fun LiveSensorChart(samples: List, unit: String) { + val axisCount = samples.maxOfOrNull { it.values.size }?.coerceAtMost(MAX_CHART_AXES) ?: 0 + val colors = liveChartColors() + val values = samples.flatMap { sample -> sample.values.take(axisCount).filter(Float::isFinite) } + val labels = LiveChartLabels( + timeAxis = stringResource(R.string.chart_time_axis), + valueAxis = stringResource(R.string.chart_value_axis, unit), + ) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + LiveSensorChartCanvas(samples, axisCount, chartScale(values), colors, labels) + if (axisCount > 0) LiveSensorChartLegend(axisCount, colors) + } +} + +@Composable +private fun liveChartColors(): List = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary, + MaterialTheme.colorScheme.error, + MaterialTheme.colorScheme.secondary, + MaterialTheme.colorScheme.onSurface, + MaterialTheme.colorScheme.outline, +) + +@Composable +private fun LiveSensorChartCanvas( + samples: List, + axisCount: Int, + scale: ChartScale, + colors: List, + labels: LiveChartLabels, +) { + val gridColor = MaterialTheme.colorScheme.outlineVariant + val axisColor = MaterialTheme.colorScheme.onSurfaceVariant + Canvas(Modifier.fillMaxWidth().height(CHART_HEIGHT)) { + val plot = ChartPlot( + left = CHART_LEFT_MARGIN.toPx(), + top = CHART_TOP_MARGIN.toPx(), + right = size.width - CHART_RIGHT_MARGIN.toPx(), + bottom = size.height - CHART_BOTTOM_MARGIN.toPx(), + ) + drawChartAxes(plot, scale, samples, gridColor, axisColor, labels) + if (samples.size >= 2) drawSensorLines(samples, axisCount, plot, scale, colors) + } +} + +@Composable +private fun LiveSensorChartLegend(axisCount: Int, colors: List) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + repeat(axisCount) { axis -> ChartLegend(axisLabel(axis, axisCount), colors[axis]) } + } +} + +@Composable +private fun ChartLegend(label: String, color: Color) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Box(Modifier.size(8.dp).background(color, CircleShape)) + Text(label, style = MaterialTheme.typography.labelMedium) + } +} + +private fun chartScale(values: List): ChartScale { + if (values.isEmpty()) return ChartScale(-1f, 1f) + val rawMinimum = values.minOrNull() ?: -1f + val rawMaximum = values.maxOrNull() ?: 1f + val padding = max((rawMaximum - rawMinimum) * CHART_RANGE_PADDING, MIN_CHART_RANGE / 2f) + return ChartScale(rawMinimum - padding, rawMaximum + padding) +} + +private fun DrawScope.drawChartAxes( + plot: ChartPlot, + scale: ChartScale, + samples: List, + gridColor: Color, + axisColor: Color, + labels: LiveChartLabels, +) { + val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = axisColor.toArgb() + textSize = 10.sp.toPx() + } + drawValueTicks(plot, scale, gridColor, labelPaint) + drawTimeTicks(plot, samples, gridColor, labelPaint) + drawLine(axisColor, Offset(plot.left, plot.top), Offset(plot.left, plot.bottom), 1.5.dp.toPx()) + drawLine(axisColor, Offset(plot.left, plot.bottom), Offset(plot.right, plot.bottom), 1.5.dp.toPx()) + drawAxisTitles(plot, labels, Paint(labelPaint).apply { textSize = 11.sp.toPx() }) +} + +private fun DrawScope.drawValueTicks(plot: ChartPlot, scale: ChartScale, gridColor: Color, paint: Paint) { + paint.textAlign = Paint.Align.RIGHT + repeat(GRID_LINE_COUNT) { index -> + val fraction = index.toFloat() / (GRID_LINE_COUNT - 1) + val y = plot.bottom - plot.height * fraction + val value = scale.minimum + scale.range * fraction + drawLine(gridColor, Offset(plot.left, y), Offset(plot.right, y), 1.dp.toPx()) + drawIntoCanvas { canvas -> + canvas.nativeCanvas.drawText(formatChartValue(value), plot.left - 7.dp.toPx(), y + 3.dp.toPx(), paint) + } + } + if (scale.minimum < 0f && scale.maximum > 0f) { + val zeroY = plot.bottom - plot.height * (-scale.minimum / scale.range) + drawLine(gridColor, Offset(plot.left, zeroY), Offset(plot.right, zeroY), 2.dp.toPx()) + } +} + +private fun DrawScope.drawTimeTicks( + plot: ChartPlot, + samples: List, + gridColor: Color, + paint: Paint, +) { + paint.textAlign = Paint.Align.CENTER + val durationSeconds = samples.durationSeconds() + repeat(TIME_TICK_COUNT) { index -> + val fraction = index.toFloat() / (TIME_TICK_COUNT - 1) + val x = plot.left + plot.width * fraction + drawLine(gridColor, Offset(x, plot.top), Offset(x, plot.bottom), 1.dp.toPx()) + drawIntoCanvas { canvas -> + canvas.nativeCanvas.drawText( + formatChartTime(durationSeconds * fraction), + x, + plot.bottom + 14.dp.toPx(), + paint, + ) + } + } +} + +private fun DrawScope.drawAxisTitles(plot: ChartPlot, labels: LiveChartLabels, paint: Paint) { + paint.textAlign = Paint.Align.CENTER + drawIntoCanvas { canvas -> + val nativeCanvas = canvas.nativeCanvas + nativeCanvas.drawText(labels.timeAxis, plot.left + plot.width / 2f, size.height - 2.dp.toPx(), paint) + val centerY = plot.top + plot.height / 2f + val titleX = 10.dp.toPx() + nativeCanvas.save() + nativeCanvas.rotate(-90f, titleX, centerY) + nativeCanvas.drawText(labels.valueAxis, titleX, centerY, paint) + nativeCanvas.restore() + } +} + +private fun DrawScope.drawSensorLines( + samples: List, + axisCount: Int, + plot: ChartPlot, + scale: ChartScale, + colors: List, +) { + val firstTimestamp = samples.first().timestampNanos + val durationNanos = (samples.last().timestampNanos - firstTimestamp).coerceAtLeast(1L) + repeat(axisCount) { axis -> + val path = Path() + var hasPoint = false + samples.forEach { sample -> + val value = sample.values.getOrNull(axis)?.takeIf(Float::isFinite) ?: return@forEach + val elapsedFraction = (sample.timestampNanos - firstTimestamp).toFloat() / durationNanos + val x = plot.left + plot.width * elapsedFraction + val y = plot.bottom - plot.height * ((value - scale.minimum) / scale.range) + if (hasPoint) path.lineTo(x, y) else path.moveTo(x, y) + hasPoint = true + } + drawPath(path, colors[axis], style = Stroke(width = 2.5.dp.toPx())) + } +} + +private fun List.durationSeconds(): Float = + if (size < 2) 0f else (last().timestampNanos - first().timestampNanos) / NANOS_PER_SECOND + +private fun formatChartTime(seconds: Float): String = ValueFormats.decimal(seconds, fractionDigits = 1) + +private fun formatChartValue(value: Float): String = when { + abs(value) >= 100f -> ValueFormats.decimal(value, fractionDigits = 0) + abs(value) >= 10f -> ValueFormats.decimal(value, fractionDigits = 1) + else -> ValueFormats.decimal(value) +} + +private data class LiveChartLabels(val timeAxis: String, val valueAxis: String) + +private data class ChartScale(val minimum: Float, val maximum: Float) { + val range: Float = maximum - minimum +} + +private data class ChartPlot(val left: Float, val top: Float, val right: Float, val bottom: Float) { + val width: Float = right - left + val height: Float = bottom - top +} + +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 +private val CHART_BOTTOM_MARGIN = 42.dp +private const val MAX_CHART_AXES = 6 +private const val GRID_LINE_COUNT = 5 +private const val TIME_TICK_COUNT = 3 +private const val MIN_CHART_RANGE = 0.001f +private const val CHART_RANGE_PADDING = 0.08f +private const val NANOS_PER_SECOND = 1_000_000_000f diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MainContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MainContract.kt index 291fed0..e0a0f9f 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MainContract.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MainContract.kt @@ -1,7 +1,8 @@ package com.tomasrepcik.sensorbox.presentation.main import androidx.navigation3.runtime.NavKey -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionState +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState import kotlinx.serialization.Serializable @Serializable @@ -26,4 +27,5 @@ data class MainState( val session: RecordingSessionState = RecordingSessionState.Idle, val keepScreenAwake: Boolean = false, val hasLoadedPreferences: Boolean = false, + val visibleFailureCode: AppErrorCode? = null, ) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MainViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MainViewModel.kt index 3d99276..852eb58 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MainViewModel.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MainViewModel.kt @@ -2,8 +2,9 @@ package com.tomasrepcik.sensorbox.presentation.main import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.tomasrepcik.sensorbox.core.error.AppFailureStore import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -15,6 +16,7 @@ import javax.inject.Inject class MainViewModel @Inject constructor( private val preferencesRepository: AppPreferencesRepository, private val sessionStore: RecordingSessionStore, + private val appFailures: AppFailureStore, ) : ViewModel() { private val mutableState = MutableStateFlow(MainState()) private var hasChosenInitialRoute = false @@ -24,6 +26,7 @@ class MainViewModel @Inject constructor( init { observePreferences() observeSession() + observeFailures() } fun navigate(route: MainRoute) { @@ -35,6 +38,10 @@ class MainViewModel @Inject constructor( navigate(MainRoute.PRIVACY) } + fun dismissFailure() { + appFailures.dismiss() + } + private fun observePreferences() { viewModelScope.launch { preferencesRepository.preferences.collect { result -> @@ -57,8 +64,9 @@ class MainViewModel @Inject constructor( hasLoadedPreferences = true, ) }, - onFailure = { + onFailure = { error -> mutableState.value = state.value.copy(hasLoadedPreferences = true) + appFailures.show(error) }, ) } @@ -72,4 +80,12 @@ class MainViewModel @Inject constructor( } } } + + private fun observeFailures() { + viewModelScope.launch { + appFailures.visibleFailure.collect { failure -> + mutableState.value = state.value.copy(visibleFailureCode = failure?.code) + } + } + } } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserComponents.kt new file mode 100644 index 0000000..031a0d3 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserComponents.kt @@ -0,0 +1,106 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +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.res.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileKind +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileSummary +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementMetadataEntry + +@Composable +internal fun MeasurementLoading() { + Column( + modifier = Modifier.fillMaxWidth().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator() + Text(stringResource(R.string.loading_measurements)) + } +} + +@Composable +internal fun ArchiveError() { + Text( + stringResource(R.string.measurements_read_failed), + modifier = Modifier.fillMaxWidth().padding(vertical = 18.dp), + color = MaterialTheme.colorScheme.error, + ) +} + +@Composable +internal fun EmptyArchive() { + Text( + stringResource(R.string.measurements_empty), + modifier = Modifier.fillMaxWidth().padding(vertical = 18.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +internal fun MetadataRow(entry: MeasurementMetadataEntry) { + DetailValueRow(metadataLabel(entry.name), entry.value) +} + +@Composable +internal fun DetailValueRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top, + ) { + Text(label, Modifier.weight(0.42f), color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, Modifier.weight(0.58f)) + } + SensorBoxSettingsDivider() +} + +@Composable +internal fun MeasurementFileRow(file: MeasurementFileSummary, onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(file.name, style = MaterialTheme.typography.titleMedium) + Text( + text = when (file.kind) { + MeasurementFileKind.SENSOR -> stringResource(R.string.sensor_samples) + MeasurementFileKind.GPS -> stringResource(R.string.gps_coordinates) + MeasurementFileKind.TEXT -> stringResource(R.string.text_data) + }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text(formatBytes(file.sizeBytes), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + SensorBoxSettingsDivider() +} + +private fun metadataLabel(value: String): String = value.replace(CAMEL_CASE_BOUNDARY, " ") + .replace('.', ' ') + .replace('_', ' ') + .replaceFirstChar(Char::titlecase) + +private fun formatBytes(value: Long): String = when { + value >= BYTES_PER_MEGABYTE -> "${value / BYTES_PER_MEGABYTE} MB" + value >= BYTES_PER_KILOBYTE -> "${value / BYTES_PER_KILOBYTE} KB" + else -> "$value B" +} + +private const val BYTES_PER_KILOBYTE = 1_024L +private const val BYTES_PER_MEGABYTE = BYTES_PER_KILOBYTE * 1_024L +private val CAMEL_CASE_BOUNDARY = Regex("(?<=[a-z0-9])(?=[A-Z])") diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreen.kt deleted file mode 100644 index da8edad..0000000 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserScreen.kt +++ /dev/null @@ -1,524 +0,0 @@ -package com.tomasrepcik.sensorbox.presentation.main - -import android.graphics.Paint -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTransformGestures -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -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.LazyListScope -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.drawscope.clipRect -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.core.format.ValueFormats -import com.tomasrepcik.sensorbox.domain.measurements.GpsCoordinate -import com.tomasrepcik.sensorbox.domain.measurements.MeasurementDetails -import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileContent -import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileKind -import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileSummary -import com.tomasrepcik.sensorbox.domain.measurements.MeasurementMetadataEntry -import com.tomasrepcik.sensorbox.domain.measurements.MeasurementSummary -import com.tomasrepcik.sensorbox.domain.measurements.SensorSeriesSample -import java.text.DateFormat -import java.util.Date -import kotlin.math.max - -@Composable -fun MeasurementsScreen( - state: MeasurementBrowserState, - onIntent: (MeasurementBrowserIntent) -> Unit, - onBack: () -> Unit, - modifier: Modifier = Modifier, -) { - LaunchedEffect(Unit) { onIntent(MeasurementBrowserIntent.RefreshMeasurements) } - SensorBoxBackScreen( - title = stringResource(R.string.measurements), - onBack = onBack, - modifier = modifier, - ) { - when { - state.isLoading && state.measurements.isEmpty() -> item { MeasurementLoading() } - - state.errorCode != null -> item { ArchiveError() } - - state.measurements.isEmpty() -> item { EmptyArchive() } - - else -> state.measurements.forEach { measurement -> - item { - MeasurementRow(measurement) { - onIntent(MeasurementBrowserIntent.OpenMeasurementDetails(measurement.id)) - } - } - } - } - if (state.isLoading && state.measurements.isNotEmpty()) item { MeasurementLoading() } - } -} - -@Composable -fun MeasurementDetailsScreen( - state: MeasurementBrowserState, - onIntent: (MeasurementBrowserIntent) -> Unit, - onBack: () -> Unit, - modifier: Modifier = Modifier, -) { - val details = state.selectedMeasurement - SensorBoxBackScreen( - title = stringResource(R.string.measurement_details), - onBack = onBack, - modifier = modifier, - itemSpacing = 0.dp, - ) { - if (details == null) { - item { ArchiveError() } - } else { - measurementDetailsItems(details, state, onIntent) - } - } -} - -private fun LazyListScope.measurementDetailsItems( - details: MeasurementDetails, - state: MeasurementBrowserState, - onIntent: (MeasurementBrowserIntent) -> Unit, -) { - item { SensorBoxSettingsSection(stringResource(R.string.measurement_summary)) } - item { MeasurementSummaryRows(details) } - item { SensorBoxSettingsSection(stringResource(R.string.measurement_metadata)) } - if (details.metadata.isEmpty()) { - item { Text(stringResource(R.string.measurement_metadata_empty), Modifier.padding(vertical = 16.dp)) } - } else { - details.metadata.forEach { entry -> item { MetadataRow(entry) } } - } - item { SensorBoxSettingsSection(stringResource(R.string.measurement_files)) } - if (details.files.isEmpty()) { - item { Text(stringResource(R.string.measurement_files_empty), Modifier.padding(vertical = 16.dp)) } - } else { - details.files.forEach { file -> - item { MeasurementFileRow(file) { onIntent(MeasurementBrowserIntent.OpenMeasurementFile(file.id)) } } - } - } - if (state.isLoading) item { MeasurementLoading() } - if (state.errorCode != null) item { ArchiveError() } -} - -@Composable -fun MeasurementFileScreen( - state: MeasurementBrowserState, - onIntent: (MeasurementBrowserIntent) -> Unit, - onBack: () -> Unit, - modifier: Modifier = Modifier, -) { - val file = state.selectedFile - val content = state.selectedFileContent - SensorBoxBackScreen( - title = file?.name ?: stringResource(R.string.measurement_file), - onBack = onBack, - modifier = modifier, - ) { - when (content) { - is MeasurementFileContent.SensorSeries -> { - item { SensorSeriesSummary(content) } - item { - StoredSensorChart( - columns = content.columns, - samples = content.samples, - timeWindow = state.sensorChartTimeWindow, - onIntent = onIntent, - ) - } - } - - is MeasurementFileContent.GpsCoordinates -> { - item { CoordinateSummary(content) } - content.coordinates.forEachIndexed { index, coordinate -> - item { CoordinateRow(index + 1, coordinate) } - } - } - - is MeasurementFileContent.Text -> item { StoredText(content) } - - null -> item { ArchiveError() } - } - } -} - -@Composable -private fun MeasurementRow(measurement: MeasurementSummary, onClick: () -> Unit) { - Column( - Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - measurement.name, - style = MaterialTheme.typography.titleMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - measurement.recordedAtText?.let { recordedAt -> - Text(recordedAt, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Text( - stringResource(R.string.measurement_file_count, measurement.fileCount), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - } - SensorBoxSettingsDivider() -} - -@Composable -private fun MeasurementSummaryRows(details: MeasurementDetails) { - Column { - DetailValueRow(stringResource(R.string.measurement_folder), details.summary.name) - details.summary.recordedAtText?.let { DetailValueRow(stringResource(R.string.measurement_started), it) } - DetailValueRow( - stringResource(R.string.measurement_files), - details.summary.fileCount.toString(), - ) - } -} - -@Composable -private fun MetadataRow(entry: MeasurementMetadataEntry) { - DetailValueRow(metadataLabel(entry.name), entry.value) -} - -@Composable -private fun DetailValueRow(label: String, value: String) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.Top, - ) { - Text(label, Modifier.weight(0.42f), color = MaterialTheme.colorScheme.onSurfaceVariant) - Text(value, Modifier.weight(0.58f)) - } - SensorBoxSettingsDivider() -} - -@Composable -private fun MeasurementFileRow(file: MeasurementFileSummary, onClick: () -> Unit) { - Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 14.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text(file.name, style = MaterialTheme.typography.titleMedium) - Text( - text = when (file.kind) { - MeasurementFileKind.SENSOR -> stringResource(R.string.sensor_samples) - MeasurementFileKind.GPS -> stringResource(R.string.gps_coordinates) - MeasurementFileKind.TEXT -> stringResource(R.string.text_data) - }, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text(formatBytes(file.sizeBytes), color = MaterialTheme.colorScheme.onSurfaceVariant) - } - SensorBoxSettingsDivider() -} - -@Composable -private fun SensorSeriesSummary(content: MeasurementFileContent.SensorSeries) { - Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { - Text( - stringResource(R.string.chart_sample_count, content.samples.size), - style = MaterialTheme.typography.titleMedium, - ) - Text(content.columns.joinToString(), color = MaterialTheme.colorScheme.onSurfaceVariant) - if (content.truncated) { - Text( - stringResource(R.string.chart_sampled_notice), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun StoredSensorChart( - columns: List, - samples: List, - timeWindow: SensorChartTimeWindow, - onIntent: (MeasurementBrowserIntent) -> Unit, -) { - if (samples.isEmpty() || columns.isEmpty()) { - Text(stringResource(R.string.no_samples), color = MaterialTheme.colorScheme.onSurfaceVariant) - return - } - val colors = listOf( - MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.tertiary, - MaterialTheme.colorScheme.error, - MaterialTheme.colorScheme.secondary, - ) - val orderedSamples = remember(samples) { samples.sortedBy(SensorSeriesSample::timestampMillis) } - val visibleSamples = remember(orderedSamples, timeWindow) { - selectSensorSamplesInTimeWindow(orderedSamples, timeWindow) - } - val fullDurationMillis = (orderedSamples.last().timestampMillis - orderedSamples.first().timestampMillis) - .coerceAtLeast(0L) - val visibleDurationMillis = (fullDurationMillis * timeWindow.visibleFraction).toLong().coerceAtLeast(0L) - val visibleStartTimestamp = orderedSamples.first().timestampMillis + - (fullDurationMillis * timeWindow.startFraction).toLong() - val visibleEndTimestamp = orderedSamples.first().timestampMillis + - (fullDurationMillis * timeWindow.endFraction).toLong() - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Canvas( - Modifier - .fillMaxWidth() - .height(260.dp) - .background(MaterialTheme.colorScheme.surfaceVariant) - .pointerInput(Unit) { - detectTransformGestures { centroid, pan, zoom, _ -> - if (zoom != 1f) { - onIntent( - MeasurementBrowserIntent.ZoomSensorChartTimeWindow( - zoomFactor = zoom, - focalPointFraction = centroid.x / size.width.coerceAtLeast(1).toFloat(), - ), - ) - } - if (pan.x != 0f) { - onIntent( - MeasurementBrowserIntent.ShiftSensorChartTimeWindow( - visibleWindowFraction = -pan.x / size.width.coerceAtLeast(1).toFloat(), - ), - ) - } - } - }, - ) { - drawStoredChart( - samples = visibleSamples, - columnCount = columns.size, - colors = colors, - visibleStartTimestamp = visibleStartTimestamp, - visibleEndTimestamp = visibleEndTimestamp, - ) - } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - TextButton( - onClick = { - onIntent(MeasurementBrowserIntent.ZoomSensorChartTimeWindow(0.5f, 0.5f)) - }, - ) { Text(stringResource(R.string.sensor_chart_zoom_out)) } - TextButton( - onClick = { onIntent(MeasurementBrowserIntent.ShowEntireSensorChartTimeRange) }, - ) { Text(stringResource(R.string.sensor_chart_show_all)) } - TextButton( - onClick = { - onIntent(MeasurementBrowserIntent.ZoomSensorChartTimeWindow(2f, 0.5f)) - }, - ) { Text(stringResource(R.string.sensor_chart_zoom_in)) } - } - Text( - stringResource( - R.string.sensor_chart_visible_duration, - formatSensorChartDuration(visibleDurationMillis), - formatSensorChartDuration(fullDurationMillis), - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - Text( - stringResource(R.string.sensor_chart_gesture_hint), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - columns.forEachIndexed { index, column -> - Row(verticalAlignment = Alignment.CenterVertically) { - Spacer(Modifier.size(10.dp).background(colors[index % colors.size])) - Spacer(Modifier.size(4.dp)) - Text(column, style = MaterialTheme.typography.bodySmall) - } - } - } - } -} - -private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawStoredChart( - samples: List, - columnCount: Int, - colors: List, - visibleStartTimestamp: Long, - visibleEndTimestamp: Long, -) { - val values = samples.flatMap(SensorSeriesSample::values) - val minimum = values.minOrNull() ?: 0.0 - val maximum = values.maxOrNull() ?: 1.0 - val range = max(maximum - minimum, MINIMUM_CHART_RANGE) - val left = CHART_LABEL_WIDTH - val widthAvailable = size.width - left - val heightAvailable = size.height - CHART_BOTTOM_PADDING - repeat(CHART_GRID_LINES + 1) { line -> - val y = heightAvailable * line / CHART_GRID_LINES - drawLine(Color.Gray.copy(alpha = 0.25f), Offset(left, y), Offset(size.width, y)) - val value = maximum - range * line / CHART_GRID_LINES - drawIntoCanvas { canvas -> - val paint = Paint().apply { - color = Color.Gray.toArgb() - textSize = 10.sp.toPx() - } - canvas.nativeCanvas.drawText(ValueFormats.decimal(value.toFloat()), 4.dp.toPx(), y + 4.dp.toPx(), paint) - } - } - val timestampRange = (visibleEndTimestamp - visibleStartTimestamp).coerceAtLeast(1L) - clipRect(left = left, top = 0f, right = size.width, bottom = heightAvailable) { - repeat(columnCount) { column -> - val path = Path() - var hasPoint = false - samples.forEach { sample -> - val value = sample.values.getOrNull(column) ?: return@forEach - val elapsedFraction = (sample.timestampMillis - visibleStartTimestamp).toFloat() / timestampRange - val x = left + widthAvailable * elapsedFraction - val y = ((maximum - value) / range * heightAvailable).toFloat() - if (!hasPoint) { - path.moveTo(x, y) - hasPoint = true - } else { - path.lineTo(x, y) - } - } - drawPath(path, colors[column % colors.size], style = Stroke(width = 2.dp.toPx())) - } - } -} - -@Composable -private fun CoordinateSummary(content: MeasurementFileContent.GpsCoordinates) { - Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { - Text( - stringResource(R.string.coordinate_count, content.coordinates.size), - style = MaterialTheme.typography.titleMedium, - ) - if (content.truncated) { - Text( - stringResource(R.string.coordinates_truncated_notice), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun CoordinateRow(index: Int, coordinate: GpsCoordinate) { - SensorBoxPanel(Modifier.padding(vertical = 5.dp)) { - Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { - Text(stringResource(R.string.coordinate_number, index), style = MaterialTheme.typography.titleSmall) - Text( - "${ValueFormats.decimal(coordinate.latitude, GPS_FRACTION_DIGITS)} , " + - ValueFormats.decimal(coordinate.longitude, GPS_FRACTION_DIGITS), - ) - Text( - formatTimestamp(coordinate.timestampMillis), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - coordinate.altitude?.let { - Text( - stringResource(R.string.coordinate_altitude, ValueFormats.decimal(it)), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - coordinate.accuracyMeters?.let { - Text( - stringResource(R.string.coordinate_accuracy, ValueFormats.decimal(it)), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -private fun StoredText(content: MeasurementFileContent.Text) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (content.truncated) { - Text( - stringResource(R.string.text_truncated_notice), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text(content.value, fontFamily = FontFamily.Monospace) - } -} - -@Composable -private fun MeasurementLoading() { - Column( - modifier = Modifier.fillMaxWidth().padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - CircularProgressIndicator() - Text(stringResource(R.string.loading_measurements)) - } -} - -@Composable -private fun ArchiveError() { - Text( - stringResource(R.string.measurements_read_failed), - modifier = Modifier.fillMaxWidth().padding(vertical = 18.dp), - color = MaterialTheme.colorScheme.error, - ) -} - -@Composable -private fun EmptyArchive() { - Text( - stringResource(R.string.measurements_empty), - modifier = Modifier.fillMaxWidth().padding(vertical = 18.dp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) -} - -private fun metadataLabel(value: String): String = value.replace('.', ' ').replace('_', ' ') - .replaceFirstChar(Char::titlecase) - -private fun formatBytes(value: Long): String = when { - value >= BYTES_PER_MEGABYTE -> "${value / BYTES_PER_MEGABYTE} MB" - value >= BYTES_PER_KILOBYTE -> "${value / BYTES_PER_KILOBYTE} KB" - else -> "$value B" -} - -private fun formatTimestamp(value: Long): String = DateFormat.getDateTimeInstance().format(Date(value)) - -private const val BYTES_PER_KILOBYTE = 1_024L -private const val BYTES_PER_MEGABYTE = BYTES_PER_KILOBYTE * 1_024L -private const val MINIMUM_CHART_RANGE = 0.0001 -private const val CHART_LABEL_WIDTH = 52f -private const val CHART_BOTTOM_PADDING = 12f -private const val CHART_GRID_LINES = 4 -private const val GPS_FRACTION_DIGITS = 6 diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModel.kt index f277b02..31edd14 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModel.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModel.kt @@ -2,6 +2,7 @@ package com.tomasrepcik.sensorbox.presentation.main import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.tomasrepcik.sensorbox.core.error.AppFailureStore import com.tomasrepcik.sensorbox.domain.measurements.MeasurementRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel @@ -12,7 +13,10 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class MeasurementBrowserViewModel @Inject constructor(private val repository: MeasurementRepository) : ViewModel() { +class MeasurementBrowserViewModel @Inject constructor( + private val repository: MeasurementRepository, + private val appFailures: AppFailureStore, +) : ViewModel() { private val mutableState = MutableStateFlow(MeasurementBrowserState()) private val mutableEffects = Channel(Channel.BUFFERED) @@ -67,6 +71,7 @@ class MeasurementBrowserViewModel @Inject constructor(private val repository: Me state.value, MeasurementBrowserResult.MeasurementsLoadFailed(error.code), ) + appFailures.show(error) }, ) } @@ -91,6 +96,7 @@ class MeasurementBrowserViewModel @Inject constructor(private val repository: Me state.value, MeasurementBrowserResult.MeasurementDetailsLoadFailed(error.code), ) + appFailures.show(error) }, ) } @@ -117,6 +123,7 @@ class MeasurementBrowserViewModel @Inject constructor(private val repository: Me state.value, MeasurementBrowserResult.MeasurementFileLoadFailed(error.code), ) + appFailures.show(error) }, ) } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementDetailsComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementDetailsComponents.kt new file mode 100644 index 0000000..fe27169 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementDetailsComponents.kt @@ -0,0 +1,46 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +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.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementDetails + +internal fun LazyListScope.measurementDetailsItems( + details: MeasurementDetails, + state: MeasurementBrowserState, + onIntent: (MeasurementBrowserIntent) -> Unit, +) { + item { SensorBoxSettingsSection(stringResource(R.string.measurement_summary)) } + item { MeasurementSummaryRows(details) } + item { SensorBoxSettingsSection(stringResource(R.string.measurement_metadata)) } + if (details.metadata.isEmpty()) { + item { Text(stringResource(R.string.measurement_metadata_empty), Modifier.padding(vertical = 16.dp)) } + } else { + details.metadata.forEach { entry -> item { MetadataRow(entry) } } + } + item { SensorBoxSettingsSection(stringResource(R.string.measurement_files)) } + if (details.files.isEmpty()) { + item { Text(stringResource(R.string.measurement_files_empty), Modifier.padding(vertical = 16.dp)) } + } else { + details.files.forEach { file -> + item { MeasurementFileRow(file) { onIntent(MeasurementBrowserIntent.OpenMeasurementFile(file.id)) } } + } + } + if (state.isLoading) item { MeasurementLoading() } + if (state.errorCode != null) item { ArchiveError() } +} + +@Composable +private fun MeasurementSummaryRows(details: MeasurementDetails) { + Column { + DetailValueRow(stringResource(R.string.measurement_folder), details.summary.name) + details.summary.recordedAtText?.let { DetailValueRow(stringResource(R.string.measurement_started), it) } + DetailValueRow(stringResource(R.string.measurement_files), details.summary.fileCount.toString()) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementDetailsScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementDetailsScreen.kt new file mode 100644 index 0000000..cea0833 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementDetailsScreen.kt @@ -0,0 +1,29 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R + +@Composable +fun MeasurementDetailsScreen( + state: MeasurementBrowserState, + onIntent: (MeasurementBrowserIntent) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val details = state.selectedMeasurement + SensorBoxBackScreen( + title = stringResource(R.string.measurement_details), + onBack = onBack, + modifier = modifier, + itemSpacing = 0.dp, + ) { + if (details == null) { + item { ArchiveError() } + } else { + measurementDetailsItems(details, state, onIntent) + } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementFileComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementFileComponents.kt new file mode 100644 index 0000000..9ea0917 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementFileComponents.kt @@ -0,0 +1,88 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +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.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.core.format.ValueFormats +import com.tomasrepcik.sensorbox.domain.measurements.GpsCoordinate +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileContent +import java.text.DateFormat +import java.util.Date + +@Composable +internal fun SensorSeriesSummary(content: MeasurementFileContent.SensorSeries) { + Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text( + stringResource(R.string.chart_sample_count, content.samples.size), + style = MaterialTheme.typography.titleMedium, + ) + Text(content.columns.joinToString(), color = MaterialTheme.colorScheme.onSurfaceVariant) + if (content.truncated) { + Text(stringResource(R.string.chart_sampled_notice), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +internal fun CoordinateSummary(content: MeasurementFileContent.GpsCoordinates) { + Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text( + stringResource(R.string.coordinate_count, content.coordinates.size), + style = MaterialTheme.typography.titleMedium, + ) + if (content.truncated) { + Text( + stringResource(R.string.coordinates_truncated_notice), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +internal fun CoordinateRow(index: Int, coordinate: GpsCoordinate) { + SensorBoxPanel(Modifier.padding(vertical = 5.dp)) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(stringResource(R.string.coordinate_number, index), style = MaterialTheme.typography.titleSmall) + Text( + "${ValueFormats.decimal(coordinate.latitude, GPS_FRACTION_DIGITS)} , " + + ValueFormats.decimal(coordinate.longitude, GPS_FRACTION_DIGITS), + ) + Text(formatTimestamp(coordinate.timestampMillis), color = MaterialTheme.colorScheme.onSurfaceVariant) + coordinate.altitude?.let { + Text( + stringResource(R.string.coordinate_altitude, ValueFormats.decimal(it)), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + coordinate.accuracyMeters?.let { + Text( + stringResource(R.string.coordinate_accuracy, ValueFormats.decimal(it)), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +internal fun StoredText(content: MeasurementFileContent.Text) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (content.truncated) { + Text(stringResource(R.string.text_truncated_notice), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text(content.value, fontFamily = FontFamily.Monospace) + } +} + +private fun formatTimestamp(value: Long): String = DateFormat.getDateTimeInstance().format(Date(value)) + +private const val GPS_FRACTION_DIGITS = 6 diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementFileScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementFileScreen.kt new file mode 100644 index 0000000..872ae93 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementFileScreen.kt @@ -0,0 +1,62 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileContent +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementMetadataEntry + +@Composable +fun MeasurementFileScreen( + state: MeasurementBrowserState, + onIntent: (MeasurementBrowserIntent) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val file = state.selectedFile + val sensorMetadata = file?.let { selectedFile -> + state.selectedMeasurement?.sensorMetadataByFile?.get(selectedFile.id) + }.orEmpty() + SensorBoxBackScreen( + title = file?.name ?: stringResource(R.string.measurement_file), + onBack = onBack, + modifier = modifier, + ) { + measurementFileItems(state.selectedFileContent, sensorMetadata, state.sensorChartTimeWindow, onIntent) + } +} + +private fun LazyListScope.measurementFileItems( + content: MeasurementFileContent?, + sensorMetadata: List, + timeWindow: SensorChartTimeWindow, + onIntent: (MeasurementBrowserIntent) -> Unit, +) { + when (content) { + is MeasurementFileContent.SensorSeries -> sensorSeriesItems(content, sensorMetadata, timeWindow, onIntent) + is MeasurementFileContent.GpsCoordinates -> gpsCoordinateItems(content) + is MeasurementFileContent.Text -> item { StoredText(content) } + null -> item { ArchiveError() } + } +} + +private fun LazyListScope.sensorSeriesItems( + content: MeasurementFileContent.SensorSeries, + metadata: List, + timeWindow: SensorChartTimeWindow, + onIntent: (MeasurementBrowserIntent) -> Unit, +) { + if (metadata.isNotEmpty()) { + item { SensorBoxSettingsSection(stringResource(R.string.sensor_details)) } + metadata.forEach { entry -> item { MetadataRow(entry) } } + } + item { SensorSeriesSummary(content) } + item { StoredSensorChart(content.columns, content.samples, timeWindow, onIntent) } +} + +private fun LazyListScope.gpsCoordinateItems(content: MeasurementFileContent.GpsCoordinates) { + item { CoordinateSummary(content) } + content.coordinates.forEachIndexed { index, coordinate -> item { CoordinateRow(index + 1, coordinate) } } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementsComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementsComponents.kt new file mode 100644 index 0000000..7f04ecf --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementsComponents.kt @@ -0,0 +1,63 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementSummary + +internal fun LazyListScope.measurementArchiveItems( + state: MeasurementBrowserState, + onIntent: (MeasurementBrowserIntent) -> Unit, +) { + when { + state.isLoading && state.measurements.isEmpty() -> item { MeasurementLoading() } + + state.errorCode != null -> item { ArchiveError() } + + state.measurements.isEmpty() -> item { EmptyArchive() } + + else -> state.measurements.forEach { measurement -> + item { + MeasurementRow(measurement) { + onIntent(MeasurementBrowserIntent.OpenMeasurementDetails(measurement.id)) + } + } + } + } + if (state.isLoading && state.measurements.isNotEmpty()) item { MeasurementLoading() } +} + +@Composable +private fun MeasurementRow(measurement: MeasurementSummary, onClick: () -> Unit) { + Column( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + measurement.name, + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + measurement.recordedAtText?.let { recordedAt -> + Text(recordedAt, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text( + stringResource(R.string.measurement_file_count, measurement.fileCount), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } + SensorBoxSettingsDivider() +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementsScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementsScreen.kt new file mode 100644 index 0000000..5298f88 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementsScreen.kt @@ -0,0 +1,24 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.tomasrepcik.sensorbox.R + +@Composable +fun MeasurementsScreen( + state: MeasurementBrowserState, + onIntent: (MeasurementBrowserIntent) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + LaunchedEffect(Unit) { onIntent(MeasurementBrowserIntent.RefreshMeasurements) } + SensorBoxBackScreen( + title = stringResource(R.string.measurements), + onBack = onBack, + modifier = modifier, + ) { + measurementArchiveItems(state, onIntent) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/NumberPickerSetting.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/NumberPickerSetting.kt new file mode 100644 index 0000000..a0d4622 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/NumberPickerSetting.kt @@ -0,0 +1,111 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import android.widget.NumberPicker +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView + +@Composable +internal fun NumberPickerSetting( + title: String, + value: Int, + valueLabel: String, + minimum: Int, + maximum: Int, + onValue: (Int) -> Unit, +) { + var showPicker by rememberSaveable { mutableStateOf(false) } + NumberPickerRow(title, value, valueLabel, onClick = { showPicker = true }) + SensorBoxSettingsDivider() + if (showPicker) { + NumberPickerDialog( + title = title, + value = value, + minimum = minimum, + maximum = maximum, + onValue = onValue, + onDismiss = { showPicker = false }, + ) + } +} + +@Composable +private fun NumberPickerRow(title: String, value: Int, valueLabel: String, onClick: () -> Unit) { + 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 = onClick) { Text(value.toString()) } + } +} + +@Composable +private fun NumberPickerDialog( + title: String, + value: Int, + minimum: Int, + maximum: Int, + onValue: (Int) -> Unit, + onDismiss: () -> Unit, +) { + var selectedValue by remember(value, minimum, maximum) { + mutableIntStateOf(value.coerceIn(minimum, maximum)) + } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { NumberPickerView(selectedValue, minimum, maximum) { selectedValue = it } }, + confirmButton = { + TextButton( + onClick = { + onDismiss() + onValue(selectedValue) + }, + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} + +@Composable +private fun NumberPickerView(value: Int, minimum: Int, maximum: Int, onValue: (Int) -> Unit) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + AndroidView( + factory = { context -> + NumberPicker(context).apply { + minValue = minimum + maxValue = maximum + wrapSelectorWheel = false + this.value = value + setOnValueChangedListener { _, _, newValue -> onValue(newValue) } + } + }, + update = { picker -> if (picker.value != value) picker.value = value }, + ) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingComponents.kt new file mode 100644 index 0000000..4eae76f --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingComponents.kt @@ -0,0 +1,267 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.Image +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.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.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.dp +import com.tomasrepcik.sensorbox.R + +@Composable +internal 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, + ) + } + } +} + +@Composable +internal fun OnboardingMessage( + page: OnboardingPage, + state: OnboardingState, + isLandscape: Boolean, + onIntent: (OnboardingIntent) -> Unit, +) { + if (isLandscape) { + LandscapeOnboardingMessage(page, state, onIntent) + } else { + PortraitOnboardingMessage(page, state, onIntent) + } +} + +@Composable +private fun PortraitOnboardingMessage( + page: OnboardingPage, + state: OnboardingState, + onIntent: (OnboardingIntent) -> Unit, +) { + Column( + modifier = Modifier + .widthIn(max = ONBOARDING_MESSAGE_MAX_WIDTH) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + OnboardingIllustration(page) + Spacer(Modifier.height(24.dp)) + OnboardingText(page) + Spacer(Modifier.height(18.dp)) + OnboardingPageButtons(page.prompt, state.recordingArchivePath, onIntent) + } +} + +@Composable +private fun LandscapeOnboardingMessage( + page: OnboardingPage, + state: OnboardingState, + onIntent: (OnboardingIntent) -> Unit, +) { + 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, imageSize = 80.dp) + Spacer(Modifier.width(32.dp)) + Column( + modifier = Modifier.widthIn(max = ONBOARDING_MESSAGE_MAX_WIDTH).weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + OnboardingText(page) + Spacer(Modifier.height(12.dp)) + OnboardingPageButtons(page.prompt, state.recordingArchivePath, onIntent) + } + } +} + +@Composable +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 +private fun OnboardingText(page: OnboardingPage) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + stringResource(page.title), + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(10.dp)) + Text( + stringResource(page.body), + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +@Composable +private fun OnboardingPageButtons(prompt: OnboardingPrompt, path: String?, onIntent: (OnboardingIntent) -> Unit) { + when (prompt) { + OnboardingPrompt.NONE -> Unit + OnboardingPrompt.POLICIES -> PolicyButtons(onIntent) + OnboardingPrompt.BATTERY -> BatteryButton(onIntent) + OnboardingPrompt.ARCHIVE -> ArchiveButton(path, onIntent) + } +} + +@Composable +private fun PolicyButtons(onIntent: (OnboardingIntent) -> Unit) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + SensorBoxSecondaryButton( + 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, + ) + } +} + +@Composable +private fun BatteryButton(onIntent: (OnboardingIntent) -> Unit) { + SensorBoxSecondaryButton( + label = stringResource(R.string.intro_battery_action), + onClick = { onIntent(OnboardingIntent.RequestBatteryOptimizationExemption) }, + modifier = Modifier.fillMaxWidth(), + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) +} + +@Composable +private fun ArchiveButton(path: String?, onIntent: (OnboardingIntent) -> Unit) { + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + SensorBoxSecondaryButton( + label = stringResource( + if (path == null) R.string.intro_archive_action else R.string.intro_archive_change_action, + ), + onClick = { onIntent(OnboardingIntent.ChooseRecordingArchive) }, + modifier = Modifier.fillMaxWidth(), + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + if (path != null) { + Spacer(Modifier.height(10.dp)) + Text( + stringResource(R.string.intro_archive_selected, path), + color = MaterialTheme.colorScheme.onPrimaryContainer, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +internal fun OnboardingControls( + pageIndex: Int, + archiveSelected: Boolean, + isLandscape: Boolean, + onIntent: (OnboardingIntent) -> Unit, + modifier: Modifier = Modifier, +) { + val isLastPage = pageIndex == ONBOARDING_PAGES.lastIndex + Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + OnboardingProgress(pageIndex) + Spacer(Modifier.height(if (isLandscape) 12.dp else 20.dp)) + OnboardingPrimaryButton( + label = stringResource(if (isLastPage) R.string.intro_finish else R.string.next), + onClick = { onIntent(onboardingForwardIntent(isLastPage)) }, + enabled = !isLastPage || archiveSelected, + ) + } +} + +@Composable +private fun OnboardingProgress(pageIndex: Int) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + ONBOARDING_PAGES.indices.forEach { index -> + val color = if (index == pageIndex) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.32f) + } + Box( + Modifier + .size(if (index == pageIndex) 10.dp else 8.dp) + .clip(CircleShape) + .background(color), + ) + } + } +} + +@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 diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingLayout.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingLayout.kt new file mode 100644 index 0000000..6f66514 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingLayout.kt @@ -0,0 +1,80 @@ +package com.tomasrepcik.sensorbox.presentation.main + +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.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp + +@Composable +internal fun OnboardingLayout( + pageIndex: Int, + state: OnboardingState, + isLandscape: Boolean, + onIntent: (OnboardingIntent) -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize().padding(vertical = if (isLandscape) 12.dp else 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + 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, + archiveSelected = state.recordingArchivePath != null, + isLandscape = isLandscape, + onIntent = onIntent, + 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 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) + +private const val ONBOARDING_TRANSITION_MILLIS = 320 diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingPages.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingPages.kt new file mode 100644 index 0000000..e83d2de --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingPages.kt @@ -0,0 +1,56 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R + +internal data class OnboardingPage( + @StringRes val title: Int, + @StringRes val body: Int, + @DrawableRes val image: Int, + val prompt: OnboardingPrompt = OnboardingPrompt.NONE, + val tintIllustration: Boolean = true, +) + +internal enum class OnboardingPrompt { NONE, POLICIES, BATTERY, ARCHIVE } + +internal val ONBOARDING_MAX_WIDTH = 960.dp +internal val ONBOARDING_MESSAGE_MAX_WIDTH = 520.dp + +internal val ONBOARDING_PAGES = listOf( + OnboardingPage( + R.string.intro_welcome_title, + R.string.intro_welcome_body, + R.drawable.ic_sensorbox_logo, + tintIllustration = false, + ), + OnboardingPage( + R.string.intro_incognito_title, + R.string.intro_incognito_body, + R.drawable.ic_onboarding_private, + ), + OnboardingPage( + R.string.intro_policy_title, + R.string.intro_policy_body, + R.drawable.ic_onboarding_policy, + OnboardingPrompt.POLICIES, + ), + OnboardingPage( + R.string.intro_lifecycle_title, + R.string.intro_lifecycle_body, + R.drawable.ic_onboarding_paused, + ), + OnboardingPage( + R.string.intro_battery_title, + R.string.intro_battery_body, + R.drawable.ic_onboarding_battery, + OnboardingPrompt.BATTERY, + ), + OnboardingPage( + R.string.intro_archive_title, + R.string.intro_archive_body, + R.drawable.ic_onboarding_storage, + OnboardingPrompt.ARCHIVE, + ), +) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingScreen.kt index 5255601..fe3229e 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingScreen.kt @@ -1,405 +1,20 @@ package com.tomasrepcik.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.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -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.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.tomasrepcik.sensorbox.R @Composable fun OnboardingScreen(state: OnboardingState, onIntent: (OnboardingIntent) -> Unit) { val pageIndex = state.page.coerceIn(ONBOARDING_PAGES.indices) BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.primaryContainer), + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.TopCenter, ) { - val isLandscape = maxWidth > maxHeight - OnboardingLayout(pageIndex, state, isLandscape, onIntent) + OnboardingLayout(pageIndex, state, isLandscape = maxWidth > maxHeight, onIntent) } } - -@Composable -private fun OnboardingLayout( - pageIndex: Int, - state: OnboardingState, - isLandscape: Boolean, - onIntent: (OnboardingIntent) -> Unit, -) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(vertical = if (isLandscape) 12.dp else 32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - 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, - archiveSelected = state.recordingArchivePath != null, - isLandscape = isLandscape, - onIntent = onIntent, - 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 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, - ) - } - } -} - -@Composable -private fun OnboardingProgress(pageIndex: Int) { - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - ONBOARDING_PAGES.indices.forEach { index -> - val color = if (index == pageIndex) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.32f) - } - Box( - Modifier - .size(if (index == pageIndex) 10.dp else 8.dp) - .clip(CircleShape) - .background(color), - ) - } - } -} - -@Composable -private fun OnboardingMessage( - page: OnboardingPage, - state: OnboardingState, - isLandscape: Boolean, - onIntent: (OnboardingIntent) -> Unit, -) { - if (isLandscape) { - LandscapeOnboardingMessage(page, state, onIntent) - } else { - PortraitOnboardingMessage(page, state, onIntent) - } -} - -@Composable -private fun PortraitOnboardingMessage( - page: OnboardingPage, - state: OnboardingState, - onIntent: (OnboardingIntent) -> Unit, -) { - Column( - modifier = Modifier - .widthIn(max = ONBOARDING_MESSAGE_MAX_WIDTH) - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - OnboardingIllustration(page) - Spacer(Modifier.height(24.dp)) - OnboardingText(page) - Spacer(Modifier.height(18.dp)) - OnboardingPageButtons(page.prompt, state.recordingArchivePath, onIntent) - } -} - -@Composable -private fun LandscapeOnboardingMessage( - page: OnboardingPage, - state: OnboardingState, - onIntent: (OnboardingIntent) -> Unit, -) { - 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, imageSize = 80.dp) - Spacer(Modifier.width(32.dp)) - Column( - modifier = Modifier.widthIn(max = ONBOARDING_MESSAGE_MAX_WIDTH).weight(1f), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - OnboardingText(page) - Spacer(Modifier.height(12.dp)) - OnboardingPageButtons(page.prompt, state.recordingArchivePath, onIntent) - } - } -} - -@Composable -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 -private fun OnboardingText(page: OnboardingPage) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - stringResource(page.title), - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onPrimaryContainer, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(10.dp)) - Text( - stringResource(page.body), - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyLarge, - ) - } -} - -@Composable -private fun OnboardingPageButtons(prompt: OnboardingPrompt, path: String?, onIntent: (OnboardingIntent) -> Unit) { - when (prompt) { - OnboardingPrompt.NONE -> Unit - OnboardingPrompt.POLICIES -> PolicyButtons(onIntent) - OnboardingPrompt.BATTERY -> BatteryButton(onIntent) - OnboardingPrompt.ARCHIVE -> ArchiveButton(path, onIntent) - } -} - -@Composable -private fun PolicyButtons(onIntent: (OnboardingIntent) -> Unit) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - SensorBoxSecondaryButton( - 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, - ) - } -} - -@Composable -private fun BatteryButton(onIntent: (OnboardingIntent) -> Unit) { - SensorBoxSecondaryButton( - label = stringResource(R.string.intro_battery_action), - onClick = { onIntent(OnboardingIntent.RequestBatteryOptimizationExemption) }, - modifier = Modifier.fillMaxWidth(), - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) -} - -@Composable -private fun ArchiveButton(path: String?, onIntent: (OnboardingIntent) -> Unit) { - Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - SensorBoxSecondaryButton( - label = stringResource( - if (path == null) R.string.intro_archive_action else R.string.intro_archive_change_action, - ), - onClick = { onIntent(OnboardingIntent.ChooseRecordingArchive) }, - modifier = Modifier.fillMaxWidth(), - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) - if (path != null) { - Spacer(Modifier.height(10.dp)) - Text( - stringResource(R.string.intro_archive_selected, path), - color = MaterialTheme.colorScheme.onPrimaryContainer, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - ) - } - } -} - -@Composable -private fun OnboardingControls( - pageIndex: Int, - archiveSelected: Boolean, - isLandscape: Boolean, - onIntent: (OnboardingIntent) -> Unit, - modifier: Modifier = Modifier, -) { - val isLastPage = pageIndex == ONBOARDING_PAGES.lastIndex - Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - OnboardingProgress(pageIndex) - Spacer(Modifier.height(if (isLandscape) 12.dp else 20.dp)) - OnboardingPrimaryButton( - label = stringResource(if (isLastPage) R.string.intro_finish else R.string.next), - onClick = { onIntent(onboardingForwardIntent(isLastPage)) }, - enabled = !isLastPage || archiveSelected, - ) - } -} - -@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 - -private data class OnboardingPage( - @StringRes val title: Int, - @StringRes val body: Int, - @DrawableRes val image: Int, - val prompt: OnboardingPrompt = OnboardingPrompt.NONE, - val tintIllustration: Boolean = true, -) - -private enum class OnboardingPrompt { NONE, POLICIES, BATTERY, ARCHIVE } - -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_sensorbox_logo, - tintIllustration = false, - ), - OnboardingPage( - R.string.intro_incognito_title, - R.string.intro_incognito_body, - R.drawable.ic_onboarding_private, - ), - OnboardingPage( - R.string.intro_policy_title, - R.string.intro_policy_body, - R.drawable.ic_onboarding_policy, - OnboardingPrompt.POLICIES, - ), - OnboardingPage( - R.string.intro_lifecycle_title, - R.string.intro_lifecycle_body, - R.drawable.ic_onboarding_paused, - ), - OnboardingPage( - R.string.intro_battery_title, - R.string.intro_battery_body, - R.drawable.ic_onboarding_battery, - OnboardingPrompt.BATTERY, - ), - OnboardingPage( - R.string.intro_archive_title, - R.string.intro_archive_body, - R.drawable.ic_onboarding_storage, - OnboardingPrompt.ARCHIVE, - ), -) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingViewModel.kt index cd4622e..8703738 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingViewModel.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OnboardingViewModel.kt @@ -1,14 +1,14 @@ package com.tomasrepcik.sensorbox.presentation.main -import android.content.Intent import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppFailureStore import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesIntent import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository import com.tomasrepcik.sensorbox.domain.recording.RecordingArchiveRepository +import com.tomasrepcik.sensorbox.domain.recording.RecordingArchiveSelection import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -22,9 +22,10 @@ import javax.inject.Inject class OnboardingViewModel @Inject constructor( private val preferencesRepository: AppPreferencesRepository, private val recordingArchive: RecordingArchiveRepository, + private val appFailures: AppFailureStore, ) : ViewModel() { private val mutableState = MutableStateFlow( - OnboardingState(recordingArchivePath = recordingArchive.path().getOrNull()), + OnboardingState(recordingArchivePath = readRecordingArchivePath()), ) private val mutableEffects = Channel(Channel.BUFFERED) @@ -56,31 +57,51 @@ class OnboardingViewModel @Inject constructor( } } - fun handleRecordingArchiveResult(resultIntent: Intent?) { - val result = resultIntent?.let(recordingArchive::select) ?: AppResult.failure( - AppError(AppErrorCode.STORAGE, "Select onboarding recording archive"), - ) + fun handleRecordingArchiveResult(selection: RecordingArchiveSelection) { + if (selection is RecordingArchiveSelection.Cancelled) return + + val result = recordingArchive.select(selection as RecordingArchiveSelection.Selected) + result.errorOrNull()?.let(appFailures::show) mutableState.value = state.value.copy( - recordingArchivePath = recordingArchive.path().getOrNull(), + recordingArchivePath = readRecordingArchivePath(), errorCode = result.errorOrNull()?.code, ) } + fun reportFailure(error: com.tomasrepcik.sensorbox.core.error.AppError) { + appFailures.show(error) + } + private fun completeOnboarding() { - if (recordingArchive.isSelected().getOrNull() != true) { - mutableState.value = state.value.copy(errorCode = AppErrorCode.STORAGE) - return + when (val selected = recordingArchive.isSelected()) { + is AppResult.Failure -> { + appFailures.show(selected.error) + return + } + + is AppResult.Success -> if (!selected.value) { + mutableState.value = state.value.copy(errorCode = AppErrorCode.STORAGE) + return + } } viewModelScope.launch { val accepted = preferencesRepository.dispatch(AppPreferencesIntent.AcceptPolicy) if (accepted is AppResult.Failure) { - mutableState.value = state.value.copy(errorCode = accepted.error.code) + appFailures.show(accepted.error) return@launch } when (val completed = preferencesRepository.dispatch(AppPreferencesIntent.CompleteIntro)) { is AppResult.Success -> mutableEffects.send(OnboardingEffect.Navigate(MainRoute.RECORD)) - is AppResult.Failure -> mutableState.value = state.value.copy(errorCode = completed.error.code) + is AppResult.Failure -> appFailures.show(completed.error) } } } + + private fun readRecordingArchivePath(): String? = recordingArchive.path().fold( + onSuccess = { it }, + onFailure = { + appFailures.show(it) + null + }, + ) } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicenseComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicenseComponents.kt new file mode 100644 index 0000000..1152588 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicenseComponents.kt @@ -0,0 +1,51 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicense + +@Composable +internal fun OpenSourceLicenseRow(name: String, onClick: () -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.large, + ) { + Text( + text = name, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + style = MaterialTheme.typography.titleMedium, + ) + } +} + +@Composable +internal fun OpenSourceLicenseDialog(license: OpenSourceLicense, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(license.name) }, + text = { + Box(Modifier.heightIn(max = 480.dp).verticalScroll(rememberScrollState())) { + Text(license.text, style = MaterialTheme.typography.bodySmall) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) } + }, + ) +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicensesScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicensesScreen.kt new file mode 100644 index 0000000..8348f05 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/OpenSourceLicensesScreen.kt @@ -0,0 +1,32 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicense + +@Composable +fun OpenSourceLicensesScreen( + state: SettingsState, + onIntent: (SettingsIntent) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val selectedLicense = state.selectedLicenseName?.let { name -> + state.openSourceLicenses.firstOrNull { it.name == name } + } + SensorBoxBackScreen( + title = stringResource(R.string.about_licenses), + onBack = onBack, + modifier = modifier, + ) { + items(state.openSourceLicenses, key = OpenSourceLicense::name) { license -> + OpenSourceLicenseRow(license.name) { onIntent(SettingsIntent.SelectOpenSourceLicense(license.name)) } + } + } + selectedLicense?.let { license -> + OpenSourceLicenseDialog(license) { onIntent(SettingsIntent.DismissOpenSourceLicense) } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/PowerSettings.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/PowerSettings.kt new file mode 100644 index 0000000..33f3345 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/PowerSettings.kt @@ -0,0 +1,61 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.tomasrepcik.sensorbox.R + +@Composable +internal fun BatteryGuardSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { + BooleanSetting( + title = stringResource(R.string.battery_guard), + description = stringResource(R.string.battery_guard_settings_description), + checked = state.preferences.recording.stopRecordingOnLowBattery, + ) { onIntent(SettingsIntent.SetStopOnLowBattery(it)) } +} + +@Composable +internal fun CpuWakeLockSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { + BooleanSetting( + title = stringResource(R.string.keep_cpu_awake), + description = stringResource(R.string.keep_cpu_awake_settings_description), + checked = state.preferences.recording.useWakeLock, + ) { onIntent(SettingsIntent.SetWakeLock(it)) } +} + +@Composable +internal fun ScreenAwakeSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { + BooleanSetting( + title = stringResource(R.string.keep_screen_awake), + description = stringResource(R.string.keep_screen_awake_settings_description), + checked = state.preferences.display.keepPhoneDisplayOn, + ) { onIntent(SettingsIntent.SetKeepScreenAwake(it)) } +} + +@Composable +internal fun BatteryOptimizationSetting(isExempt: Boolean, onIntent: (SettingsIntent) -> Unit) { + 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(), + ) + } + } + SensorBoxSettingsDivider() +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordContent.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordContent.kt new file mode 100644 index 0000000..2240913 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordContent.kt @@ -0,0 +1,75 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor + +@Composable +@OptIn(ExperimentalFoundationApi::class) +internal fun RecordContent(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + LazyColumn(contentPadding = PaddingValues(start = 24.dp, top = 8.dp, end = 24.dp, bottom = 24.dp)) { + stickyHeader { + Surface(color = MaterialTheme.colorScheme.background) { + Column { + RecordHeader( + onMeasurements = { onIntent(RecordingIntent.Navigate(MainRoute.MEASUREMENTS)) }, + onOptions = { onIntent(RecordingIntent.Navigate(MainRoute.SETTINGS)) }, + ) + Spacer(Modifier.height(18.dp)) + } + } + } + phoneSourceItems(state, onIntent) + if (state.isWatchConnected) watchSourceItems(state, onIntent) + item { RecordingMessageText(state.message) } + } +} + +private fun LazyListScope.phoneSourceItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + item { + GpsRow( + selected = state.includesGps, + onToggle = { onIntent(RecordingIntent.ToggleGps) }, + onInfo = { onIntent(RecordingIntent.OpenSensorDetails(null)) }, + ) + } + item { SourceDivider() } + items(state.sensors, key = SensorDescriptor::type) { sensor -> + SensorSourceItem( + sensor = sensor, + selected = sensor.type in state.selectedSensorIds, + onToggle = { onIntent(RecordingIntent.ToggleSensor(sensor.type)) }, + onInfo = { onIntent(RecordingIntent.OpenSensorDetails(sensor.type)) }, + ) + } +} + +private fun LazyListScope.watchSourceItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + item { WearSectionHeader() } + item { + GpsRow( + selected = state.watchIncludesGps, + onToggle = { onIntent(RecordingIntent.ToggleWatchGps) }, + ) + } + item { SourceDivider() } + items(state.watchSensors, key = { "watch_${it.type}" }) { sensor -> + SensorSourceItem( + sensor = sensor, + selected = sensor.type in state.selectedWatchSensorIds, + onToggle = { onIntent(RecordingIntent.ToggleWatchSensor(sensor.type)) }, + onInfo = { onIntent(RecordingIntent.OpenSensorDetails(sensor.type, RecordingDevice.WATCH)) }, + ) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreen.kt index f8bc292..45df5d6 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordScreen.kt @@ -1,305 +1,15 @@ package com.tomasrepcik.sensorbox.presentation.main -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable 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.height -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.items -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 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.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor @Composable fun RecordScreen(state: RecordingState, onIntent: (RecordingIntent) -> Unit, modifier: Modifier = Modifier) { Column(modifier.fillMaxSize()) { - Box(Modifier.weight(1f)) { - RecordContent(state, onIntent) - } + Box(Modifier.weight(1f)) { RecordContent(state, onIntent) } SensorSelectionTopBar(state, onIntent) } } - -@Composable -@OptIn(ExperimentalFoundationApi::class) -private fun RecordContent(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { - LazyColumn( - contentPadding = PaddingValues(start = 24.dp, top = 8.dp, end = 24.dp, bottom = 24.dp), - ) { - stickyHeader { - Surface(color = MaterialTheme.colorScheme.background) { - Column { - RecordHeader( - onMeasurements = { onIntent(RecordingIntent.Navigate(MainRoute.MEASUREMENTS)) }, - onOptions = { onIntent(RecordingIntent.Navigate(MainRoute.SETTINGS)) }, - ) - Spacer(Modifier.height(18.dp)) - } - } - } - item { - GpsRow( - selected = state.includesGps, - onToggle = { onIntent(RecordingIntent.ToggleGps) }, - onInfo = { onIntent(RecordingIntent.OpenSensorDetails(null)) }, - ) - } - item { SourceDivider() } - items(state.sensors, key = SensorDescriptor::type) { sensor -> - 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.isWatchConnected) { - item { WearSectionHeader() } - item { - GpsRow( - selected = state.watchIncludesGps, - onToggle = { onIntent(RecordingIntent.ToggleWatchGps) }, - ) - } - item { SourceDivider() } - items(state.watchSensors, key = { "watch_${it.type}" }) { sensor -> - Column { - SensorRow( - sensor = sensor, - selected = sensor.type in state.selectedWatchSensorIds, - onToggle = { onIntent(RecordingIntent.ToggleWatchSensor(sensor.type)) }, - onInfo = { - onIntent( - RecordingIntent.OpenSensorDetails( - sensor.type, - RecordingDevice.WATCH, - ), - ) - }, - ) - SourceDivider() - } - } - } - item { RecordingMessageText(state.message) } - } -} - -@Composable -private fun WearSectionHeader() { - Text( - stringResource(R.string.watch_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)? = null) { - 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(onMeasurements: () -> Unit, onOptions: () -> Unit) { - SensorBoxTopAppBar( - title = stringResource(R.string.sources), - actions = { - IconButton(onClick = onMeasurements) { - Icon( - painterResource(R.drawable.ic_baseline_folder), - contentDescription = stringResource(R.string.measurements), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - IconButton(onClick = onOptions) { - Icon( - painterResource(R.drawable.ic_baseline_settings_24), - contentDescription = stringResource(R.string.options), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - ) -} - -@Composable -private fun SensorRow( - sensor: SensorDescriptor, - selected: Boolean, - onToggle: () -> Unit, - onInfo: (() -> Unit)? = null, -) { - 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 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, - ) { - 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, - ) - onInfo?.let { showInfo -> - IconButton(onClick = showInfo) { - 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 SourceDivider() { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.72f)) -} - -@Composable -private fun SensorSelectionTopBar( - state: RecordingState, - onIntent: (RecordingIntent) -> Unit, - modifier: Modifier = Modifier, -) { - val sensorCount = selectedSourceCount(state) - Surface( - modifier = modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.background, - shadowElevation = 8.dp, - ) { - Box( - Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 14.dp), - contentAlignment = Alignment.Center, - ) { - SensorBoxPrimaryButton( - label = stringResource(R.string.continue_action), - onClick = { onIntent(RecordingIntent.OpenRecordingSetup) }, - modifier = Modifier.fillMaxWidth(), - enabled = sensorCount > 0, - ) - } - } -} - -private fun selectedSourceCount(state: RecordingState): Int = state.selectedSensorIds.size + - state.selectedWatchSensorIds.size + - (if (state.includesGps) 1 else 0) + - (if (state.watchIncludesGps) 1 else 0) + - (if (state.activityRecognition) 1 else 0) + - (if (state.significantMotion) 1 else 0) - -@Composable -fun RecordingMessageText(message: RecordingMessage) { - if (message == RecordingMessage.NONE) return - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - shape = MaterialTheme.shapes.medium, - tonalElevation = 1.dp, - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.Top, - ) { - Icon( - painter = painterResource(R.drawable.ic_info), - contentDescription = null, - modifier = Modifier.size(22.dp), - ) - Spacer(Modifier.width(12.dp)) - Column { - Text( - text = stringResource(messageTitleResource(message)), - style = MaterialTheme.typography.titleSmall, - ) - Spacer(Modifier.height(2.dp)) - Text( - text = stringResource(messageTextResource(message)), - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } -} - -@StringRes -private fun messageTitleResource(message: RecordingMessage): Int = when (message) { - RecordingMessage.PICK_AT_LEAST_ONE_SOURCE -> R.string.message_pick_source_title - RecordingMessage.RECORDING_ARCHIVE_REQUIRED -> R.string.message_recording_archive_required_title - RecordingMessage.PERMISSION_REQUIRED -> R.string.message_permission_required_title - RecordingMessage.WATCH_PERMISSION_REQUIRED -> R.string.message_wear_permission_required_title - RecordingMessage.RECORDING_FAILED -> R.string.message_recording_failed_title - RecordingMessage.NONE -> R.string.app_name -} - -@StringRes -private fun messageTextResource(message: RecordingMessage): Int = when (message) { - RecordingMessage.PICK_AT_LEAST_ONE_SOURCE -> R.string.message_pick_source - RecordingMessage.RECORDING_ARCHIVE_REQUIRED -> R.string.message_recording_archive_required - RecordingMessage.PERMISSION_REQUIRED -> R.string.message_permission_required - RecordingMessage.WATCH_PERMISSION_REQUIRED -> R.string.message_wear_permission_required - RecordingMessage.RECORDING_FAILED -> R.string.message_recording_failed - RecordingMessage.NONE -> R.string.app_name -} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingContract.kt index 4c78933..3abc2cb 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingContract.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingContract.kt @@ -2,9 +2,11 @@ package com.tomasrepcik.sensorbox.presentation.main import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.preferences.AppPreferences +import com.tomasrepcik.sensorbox.domain.preview.GpsPreviewData +import com.tomasrepcik.sensorbox.domain.preview.SensorPreviewData import com.tomasrepcik.sensorbox.domain.recording.RecordingSetup import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState enum class RecordingMessage { NONE, @@ -46,6 +48,8 @@ data class RecordingState( val startCountdownSeconds: Int? = null, val message: RecordingMessage = RecordingMessage.NONE, val errorCode: AppErrorCode? = null, + val gpsPreview: GpsPreviewData = GpsPreviewData(hasPermission = false), + val sensorPreview: SensorPreviewData = SensorPreviewData(isAvailable = true), ) { fun toRecordingSetup() = RecordingSetup( sensorIds = selectedSensorIds, @@ -97,11 +101,16 @@ sealed interface RecordingIntent { data class SetKeepScreenAwake(val enabled: Boolean) : RecordingIntent data class SetGpsInterval(val seconds: Int) : RecordingIntent data class SetGpsDistance(val meters: Int) : RecordingIntent + data object StartGpsPreview : RecordingIntent + data class StartSensorPreview(val sensorType: Int) : RecordingIntent + data object StopPreview : RecordingIntent + data object RequestLocationPreviewPermission : RecordingIntent } sealed interface RecordingEffect { data object PickRecordingArchive : RecordingEffect data class RequestPermissions(val permissions: Set) : RecordingEffect + data object RequestLocationPreviewPermission : RecordingEffect data class Navigate(val route: MainRoute) : RecordingEffect } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingMessage.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingMessage.kt new file mode 100644 index 0000000..477965c --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingMessage.kt @@ -0,0 +1,67 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R + +@Composable +fun RecordingMessageText(message: RecordingMessage) { + if (message == RecordingMessage.NONE) return + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + shape = MaterialTheme.shapes.medium, + tonalElevation = 1.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.Top, + ) { + Icon(painterResource(R.drawable.ic_info), null, Modifier.size(22.dp)) + Spacer(Modifier.width(12.dp)) + Column { + Text(stringResource(messageTitleResource(message)), style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(2.dp)) + Text(stringResource(messageTextResource(message)), style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +@StringRes +private fun messageTitleResource(message: RecordingMessage): Int = when (message) { + RecordingMessage.PICK_AT_LEAST_ONE_SOURCE -> R.string.message_pick_source_title + RecordingMessage.RECORDING_ARCHIVE_REQUIRED -> R.string.message_recording_archive_required_title + RecordingMessage.PERMISSION_REQUIRED -> R.string.message_permission_required_title + RecordingMessage.WATCH_PERMISSION_REQUIRED -> R.string.message_wear_permission_required_title + RecordingMessage.RECORDING_FAILED -> R.string.message_recording_failed_title + RecordingMessage.NONE -> R.string.app_name +} + +@StringRes +private fun messageTextResource(message: RecordingMessage): Int = when (message) { + RecordingMessage.PICK_AT_LEAST_ONE_SOURCE -> R.string.message_pick_source + RecordingMessage.RECORDING_ARCHIVE_REQUIRED -> R.string.message_recording_archive_required + RecordingMessage.PERMISSION_REQUIRED -> R.string.message_permission_required + RecordingMessage.WATCH_PERMISSION_REQUIRED -> R.string.message_wear_permission_required + RecordingMessage.RECORDING_FAILED -> R.string.message_recording_failed + RecordingMessage.NONE -> R.string.app_name +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupComponents.kt new file mode 100644 index 0000000..7791c8d --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupComponents.kt @@ -0,0 +1,179 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R + +@Composable +internal fun MeasurementNameSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + 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 +internal fun RecordingTimingSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + Column { + StepSetting( + stringResource(R.string.start_delay), + state.startDelaySeconds, + pluralStringResource(R.plurals.seconds_count, state.startDelaySeconds, state.startDelaySeconds), + 0, + 86_400, + ) { onIntent(RecordingIntent.SetStartDelay(it)) } + StepSetting( + stringResource(R.string.recording_duration), + state.durationSeconds, + pluralStringResource(R.plurals.seconds_count, state.durationSeconds, state.durationSeconds), + 0, + 86_400, + ) { onIntent(RecordingIntent.SetDuration(it)) } + } +} + +@Composable +internal fun NotesSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + OutlinedTextField( + value = state.notes, + onValueChange = { onIntent(RecordingIntent.SetNotes(it)) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + label = { Text(stringResource(R.string.recording_notes)) }, + supportingText = { Text(stringResource(R.string.recording_notes_description)) }, + minLines = 2, + ) + SensorBoxSettingsDivider() +} + +@Composable +internal fun AlarmsSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + 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 +internal fun ActivityRecognitionSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + BooleanSetting( + title = stringResource(R.string.activity_recognition), + description = stringResource(R.string.activity_recognition_description), + checked = state.activityRecognition, + ) { onIntent(RecordingIntent.SetActivityRecognition(it)) } + if (state.activityRecognition) { + StepSetting( + stringResource(R.string.activity_recognition_period), + state.activityRecognitionPeriodSeconds, + pluralStringResource( + R.plurals.seconds_count, + state.activityRecognitionPeriodSeconds, + state.activityRecognitionPeriodSeconds, + ), + 1, + 3_600, + ) { onIntent(RecordingIntent.SetActivityRecognitionPeriod(it)) } + } +} + +@Composable +internal fun SignificantMotionSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + BooleanSetting( + title = stringResource(R.string.significant_motion), + description = stringResource(R.string.significant_motion_description), + checked = state.significantMotion, + ) { onIntent(RecordingIntent.SetSignificantMotionRecording(it)) } +} + +@Composable +internal fun RecordingArchivePanel(path: String?, onChoose: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(stringResource(R.string.recording_archive), style = MaterialTheme.typography.titleMedium) + Text( + path ?: stringResource(R.string.choose_recording_archive), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(14.dp)) + SensorBoxSecondaryButton(stringResource(if (path == null) R.string.choose else R.string.change), onChoose) + } + SensorBoxSettingsDivider() +} + +@Composable +internal fun BatterySetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + BooleanSetting( + title = stringResource(R.string.battery_guard), + description = stringResource(R.string.battery_guard_setup_description), + checked = state.preferences.recording.stopRecordingOnLowBattery, + ) { onIntent(RecordingIntent.SetStopOnLowBattery(it)) } +} + +@Composable +internal fun WakeLockSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + BooleanSetting( + title = stringResource(R.string.keep_cpu_awake), + description = stringResource(R.string.keep_cpu_awake_setup_description), + checked = state.preferences.recording.useWakeLock, + ) { onIntent(RecordingIntent.SetWakeLock(it)) } +} + +@Composable +internal fun KeepScreenAwakeSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + BooleanSetting( + title = stringResource(R.string.keep_screen_awake), + description = stringResource(R.string.keep_screen_awake_setup_description), + checked = state.preferences.display.keepPhoneDisplayOn, + ) { onIntent(RecordingIntent.SetKeepScreenAwake(it)) } +} + +@Composable +internal fun GpsIntervalSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + val seconds = state.preferences.recording.gpsIntervalSeconds + StepSetting( + stringResource(R.string.gps_interval), + seconds, + pluralStringResource(R.plurals.seconds_count, seconds, seconds), + 1, + 3_600, + ) { onIntent(RecordingIntent.SetGpsInterval(it)) } +} + +@Composable +internal fun GpsDistanceSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + val meters = state.preferences.recording.gpsMinDistanceMeters + StepSetting( + stringResource(R.string.gps_minimum_distance), + meters, + pluralStringResource(R.plurals.meters_count, meters, meters), + 0, + 10_000, + ) { onIntent(RecordingIntent.SetGpsDistance(it)) } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupContent.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupContent.kt new file mode 100644 index 0000000..ce16a3d --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupContent.kt @@ -0,0 +1,66 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R + +@Composable +internal fun RecordingSetupContent(state: RecordingState, onIntent: (RecordingIntent) -> Unit, onBack: () -> Unit) { + SensorBoxBackScreen( + title = stringResource(R.string.recording_setup), + onBack = onBack, + bottomPadding = 24.dp, + itemSpacing = 0.dp, + ) { + storageSetupItems(state, onIntent) + measurementDetailsSetupItems(state, onIntent) + timingSetupItems(state, onIntent) + sourceSetupItems(state, onIntent) + recordingOptionSetupItems(state, onIntent) + if (state.includesGps) locationSetupItems(state, onIntent) + item { RecordingMessageText(state.message) } + } +} + +private fun LazyListScope.storageSetupItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + item { SensorBoxSettingsSection(stringResource(R.string.setup_storage_category)) } + item { RecordingArchivePanel(state.recordingArchivePath) { onIntent(RecordingIntent.ChooseRecordingArchive) } } +} + +private fun LazyListScope.measurementDetailsSetupItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + item { SensorBoxSettingsSection(stringResource(R.string.setup_details_category)) } + item { MeasurementNameSetup(state, onIntent) } + item { NotesSetup(state, onIntent) } + item { AlarmsSetup(state, onIntent) } +} + +private fun LazyListScope.timingSetupItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + item { SensorBoxSettingsSection(stringResource(R.string.setup_timing_category)) } + item { RecordingTimingSetup(state, onIntent) } +} + +private fun LazyListScope.sourceSetupItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + item { SensorBoxSettingsSection(stringResource(R.string.setup_sources_category)) } + item { + SamplingSetting(state.preferences.recording.sensorSamplingPeriod) { index -> + onIntent(RecordingIntent.SetSamplingPeriod(index)) + } + } + item { ActivityRecognitionSetup(state, onIntent) } + item { SignificantMotionSetup(state, onIntent) } +} + +private fun LazyListScope.recordingOptionSetupItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + item { SensorBoxSettingsSection(stringResource(R.string.settings_recording_category)) } + item { BatterySetup(state, onIntent) } + item { WakeLockSetup(state, onIntent) } + item { KeepScreenAwakeSetup(state, onIntent) } +} + +private fun LazyListScope.locationSetupItems(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { + item { SensorBoxSettingsSection(stringResource(R.string.settings_location_category)) } + item { GpsIntervalSetup(state, onIntent) } + item { GpsDistanceSetup(state, onIntent) } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupScreen.kt index ade1f46..1b95c68 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupScreen.kt @@ -1,25 +1,10 @@ package com.tomasrepcik.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.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.width -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -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.pluralStringResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.tomasrepcik.sensorbox.R @Composable fun RecordingSetupScreen( @@ -29,247 +14,7 @@ fun RecordingSetupScreen( onBack: () -> Unit = { onIntent(RecordingIntent.ReturnToSensorSelection) }, ) { Column(modifier.fillMaxSize()) { - Box(Modifier.weight(1f)) { - RecordingSetupContent(state, onIntent, onBack) - } + Box(Modifier.weight(1f)) { RecordingSetupContent(state, onIntent, onBack) } RecordingSetupTopBar(state, onIntent) } } - -@Composable -private fun RecordingSetupContent(state: RecordingState, onIntent: (RecordingIntent) -> Unit, onBack: () -> Unit) { - SensorBoxBackScreen( - title = stringResource(R.string.recording_setup), - onBack = onBack, - bottomPadding = 24.dp, - itemSpacing = 0.dp, - ) { - item { SensorBoxSettingsSection(stringResource(R.string.setup_storage_category)) } - item { RecordingArchivePanel(state.recordingArchivePath) { onIntent(RecordingIntent.ChooseRecordingArchive) } } - item { SensorBoxSettingsSection(stringResource(R.string.setup_details_category)) } - item { MeasurementNameSetup(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) } - } - item { RecordingMessageText(state.message) } - } -} - -@Composable -private fun MeasurementNameSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { - 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 { - StepSetting( - stringResource(R.string.start_delay), - state.startDelaySeconds, - pluralStringResource(R.plurals.seconds_count, state.startDelaySeconds, state.startDelaySeconds), - 0, - 86_400, - ) { onIntent(RecordingIntent.SetStartDelay(it)) } - StepSetting( - stringResource(R.string.recording_duration), - state.durationSeconds, - pluralStringResource(R.plurals.seconds_count, state.durationSeconds, state.durationSeconds), - 0, - 86_400, - ) { onIntent(RecordingIntent.SetDuration(it)) } - } -} - -@Composable -private fun NotesAndAlarmsSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { - OutlinedTextField( - value = state.notes, - onValueChange = { onIntent(RecordingIntent.SetNotes(it)) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), - label = { Text(stringResource(R.string.recording_notes)) }, - supportingText = { Text(stringResource(R.string.recording_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 { - BooleanSetting( - title = stringResource(R.string.activity_recognition), - description = stringResource(R.string.activity_recognition_description), - checked = state.activityRecognition, - ) { onIntent(RecordingIntent.SetActivityRecognition(it)) } - if (state.activityRecognition) { - StepSetting( - stringResource(R.string.activity_recognition_period), - state.activityRecognitionPeriodSeconds, - pluralStringResource( - R.plurals.seconds_count, - state.activityRecognitionPeriodSeconds, - state.activityRecognitionPeriodSeconds, - ), - 1, - 3_600, - ) { onIntent(RecordingIntent.SetActivityRecognitionPeriod(it)) } - } - BooleanSetting( - title = stringResource(R.string.significant_motion), - description = stringResource(R.string.significant_motion_description), - checked = state.significantMotion, - ) { onIntent(RecordingIntent.SetSignificantMotionRecording(it)) } - } -} - -@Composable -private fun RecordingArchivePanel(path: String?, onChoose: () -> Unit) { - Row( - Modifier.fillMaxWidth().padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { - Text(stringResource(R.string.recording_archive), style = MaterialTheme.typography.titleMedium) - Text( - path ?: stringResource(R.string.choose_recording_archive), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.width(14.dp)) - SensorBoxSecondaryButton( - stringResource(if (path == null) R.string.choose else R.string.change), - onChoose, - ) - } - SensorBoxSettingsDivider() -} - -@Composable -private fun BatterySetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { - BooleanSetting( - title = stringResource(R.string.battery_guard), - description = stringResource(R.string.battery_guard_setup_description), - checked = state.preferences.recording.stopRecordingOnLowBattery, - ) { onIntent(RecordingIntent.SetStopOnLowBattery(it)) } -} - -@Composable -private fun WakeLockSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { - BooleanSetting( - title = stringResource(R.string.keep_cpu_awake), - description = stringResource(R.string.keep_cpu_awake_setup_description), - checked = state.preferences.recording.useWakeLock, - ) { onIntent(RecordingIntent.SetWakeLock(it)) } -} - -@Composable -private fun KeepScreenAwakeSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { - BooleanSetting( - title = stringResource(R.string.keep_screen_awake), - description = stringResource(R.string.keep_screen_awake_setup_description), - checked = state.preferences.display.keepPhoneDisplayOn, - ) { onIntent(RecordingIntent.SetKeepScreenAwake(it)) } -} - -@Composable -private fun GpsIntervalSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { - StepSetting( - stringResource(R.string.gps_interval), - state.preferences.recording.gpsIntervalSeconds, - pluralStringResource( - R.plurals.seconds_count, - state.preferences.recording.gpsIntervalSeconds, - state.preferences.recording.gpsIntervalSeconds, - ), - 1, - 3_600, - ) { - onIntent(RecordingIntent.SetGpsInterval(it)) - } -} - -@Composable -private fun GpsDistanceSetup(state: RecordingState, onIntent: (RecordingIntent) -> Unit) { - StepSetting( - stringResource(R.string.gps_minimum_distance), - state.preferences.recording.gpsMinDistanceMeters, - pluralStringResource( - R.plurals.meters_count, - state.preferences.recording.gpsMinDistanceMeters, - state.preferences.recording.gpsMinDistanceMeters, - ), - 0, - 10_000, - ) { - onIntent(RecordingIntent.SetGpsDistance(it)) - } -} - -@Composable -private fun RecordingSetupTopBar( - state: RecordingState, - onIntent: (RecordingIntent) -> Unit, - modifier: Modifier = Modifier, -) { - val sourceCount = setupSourceCount(state) - Surface( - modifier = modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.background, - shadowElevation = 8.dp, - ) { - Box( - Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 14.dp), - contentAlignment = Alignment.Center, - ) { - SensorBoxPrimaryButton( - label = state.startCountdownSeconds?.let { seconds -> - pluralStringResource(R.plurals.starting_in_seconds, seconds, seconds) - } ?: stringResource( - if (state.isStarting) R.string.starting_recording else R.string.start_recording, - ), - onClick = { onIntent(RecordingIntent.StartRecording) }, - modifier = Modifier.fillMaxWidth(), - enabled = state.recordingArchivePath != null && sourceCount > 0 && !state.isStarting, - loading = state.isStarting, - ) - } - } -} - -private fun setupSourceCount(state: RecordingState): Int = - state.selectedSensorIds.size + state.selectedWatchSensorIds.size + - (if (state.includesGps) 1 else 0) + (if (state.watchIncludesGps) 1 else 0) + - (if (state.activityRecognition) 1 else 0) + (if (state.significantMotion) 1 else 0) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupTopBar.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupTopBar.kt new file mode 100644 index 0000000..8d1f537 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSetupTopBar.kt @@ -0,0 +1,48 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R + +@Composable +internal fun RecordingSetupTopBar( + state: RecordingState, + onIntent: (RecordingIntent) -> Unit, + modifier: Modifier = Modifier, +) { + val sourceCount = setupSourceCount(state) + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.background, + shadowElevation = 8.dp, + ) { + Box( + Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + SensorBoxPrimaryButton( + label = state.startCountdownSeconds?.let { seconds -> + pluralStringResource(R.plurals.starting_in_seconds, seconds, seconds) + } ?: stringResource(if (state.isStarting) R.string.starting_recording else R.string.start_recording), + onClick = { onIntent(RecordingIntent.StartRecording) }, + modifier = Modifier.fillMaxWidth(), + enabled = state.recordingArchivePath != null && sourceCount > 0 && !state.isStarting, + loading = state.isStarting, + ) + } + } +} + +private fun setupSourceCount(state: RecordingState): Int = + state.selectedSensorIds.size + state.selectedWatchSensorIds.size + + (if (state.includesGps) 1 else 0) + (if (state.watchIncludesGps) 1 else 0) + + (if (state.activityRecognition) 1 else 0) + (if (state.significantMotion) 1 else 0) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSourceComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSourceComponents.kt new file mode 100644 index 0000000..32080aa --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingSourceComponents.kt @@ -0,0 +1,141 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.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.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.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor + +@Composable +internal fun SensorSourceItem( + sensor: SensorDescriptor, + selected: Boolean, + onToggle: () -> Unit, + onInfo: (() -> Unit)?, +) { + Column { + SensorRow(sensor, selected, onToggle, onInfo) + SourceDivider() + } +} + +@Composable +internal fun WearSectionHeader() { + Text( + stringResource(R.string.watch_sensors), + modifier = Modifier.fillMaxWidth().padding(top = 28.dp, bottom = 8.dp), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +internal fun GpsRow(selected: Boolean, onToggle: () -> Unit, onInfo: (() -> Unit)? = null) { + 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 +internal fun RecordHeader(onMeasurements: () -> Unit, onOptions: () -> Unit) { + SensorBoxTopAppBar( + title = stringResource(R.string.sources), + actions = { + IconButton(onClick = onMeasurements) { + Icon( + painterResource(R.drawable.ic_baseline_folder), + contentDescription = stringResource(R.string.measurements), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onOptions) { + Icon( + painterResource(R.drawable.ic_baseline_settings_24), + contentDescription = stringResource(R.string.options), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) +} + +@Composable +private fun SensorRow(sensor: SensorDescriptor, selected: Boolean, onToggle: () -> Unit, onInfo: (() -> Unit)?) { + 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 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, + ) { + 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, + ) + onInfo?.let { SourceInfoButton(informationDescription, it) } + Spacer(Modifier.width(4.dp)) + Checkbox(checked = selected, onCheckedChange = { onToggle() }) + } +} + +@Composable +private fun SourceInfoButton(description: String, onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon( + painterResource(R.drawable.ic_info), + contentDescription = description, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } +} + +@Composable +internal fun SourceDivider() { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.72f)) +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingViewModel.kt index 534c40a..31fb4ee 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingViewModel.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/RecordingViewModel.kt @@ -1,28 +1,22 @@ package com.tomasrepcik.sensorbox.presentation.main -import android.content.Intent import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppFailureStore import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesIntent import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository +import com.tomasrepcik.sensorbox.domain.preview.DevicePreviewRepository import com.tomasrepcik.sensorbox.domain.recording.RecordingArchiveRepository +import com.tomasrepcik.sensorbox.domain.recording.RecordingArchiveSelection import com.tomasrepcik.sensorbox.domain.recording.RecordingControlUseCase import com.tomasrepcik.sensorbox.domain.recording.RecordingPermissionsUseCase import com.tomasrepcik.sensorbox.domain.recording.RecordingSetup -import com.tomasrepcik.sensorbox.domain.sensors.AvailableSensorsUseCase -import com.tomasrepcik.sensorbox.domain.sensors.WatchSensorCatalogStore -import com.tomasrepcik.sensorbox.domain.sensors.toSensorDescriptor -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionState -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStore -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY -import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH -import com.tomasrepcik.sensorbox.wearoslib.connectivity.ObserveWearCapabilityUseCase -import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnection -import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand +import com.tomasrepcik.sensorbox.domain.sensors.AvailableRecordingSourcesUseCase +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel @@ -31,7 +25,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -41,20 +34,20 @@ import javax.inject.Inject @Suppress("TooManyFunctions") class RecordingViewModel @Inject constructor( private val preferencesRepository: AppPreferencesRepository, - private val availableSensors: AvailableSensorsUseCase, + private val availableSources: AvailableRecordingSourcesUseCase, private val recordingArchive: RecordingArchiveRepository, private val permissions: RecordingPermissionsUseCase, private val recording: RecordingControlUseCase, private val sessionStore: RecordingSessionStore, - private val observeWatchCapability: ObserveWearCapabilityUseCase, - private val sendWatchCommand: SendWearCommandUseCase, - private val watchSensorCatalog: WatchSensorCatalogStore, private val elapsedRealtimeClock: ElapsedRealtimeClock, + private val appFailures: AppFailureStore, + private val devicePreview: DevicePreviewRepository, ) : ViewModel() { private val mutableState = MutableStateFlow(initialState()) private val mutableEffects = Channel(Channel.BUFFERED) private var elapsedJob: Job? = null private var startJob: Job? = null + private var previewJob: Job? = null val state: StateFlow = mutableState.asStateFlow() val effects = mutableEffects.receiveAsFlow() @@ -62,11 +55,15 @@ class RecordingViewModel @Inject constructor( init { observePreferences() observeRecordingSession() - observeWatchConnection() - observeWatchSensors() + observeRecordingFailures() + observeAvailableSources() } fun accept(intent: RecordingIntent) { + intent.toPreferencesIntent()?.let { + updatePreference(it) + return + } when (intent) { RecordingIntent.StartRecording -> startRecording() @@ -74,39 +71,45 @@ class RecordingViewModel @Inject constructor( is RecordingIntent.AddAnnotation -> recording.annotate(intent.text).showFailure() - is RecordingIntent.SetSamplingPeriod -> updatePreference( - AppPreferencesIntent.SetSensorSamplingPeriod(intent.index), - ) - - is RecordingIntent.SetStopOnLowBattery -> updatePreference( - AppPreferencesIntent.SetStopOnLowBattery(intent.enabled), - ) - - is RecordingIntent.SetWakeLock -> updatePreference(AppPreferencesIntent.SetWakeLock(intent.enabled)) + RecordingIntent.StartGpsPreview -> startGpsPreview() - is RecordingIntent.SetKeepScreenAwake -> updatePreference( - AppPreferencesIntent.SetKeepPhoneDisplayOn(intent.enabled), - ) + is RecordingIntent.StartSensorPreview -> startSensorPreview(intent.sensorType) - is RecordingIntent.SetGpsInterval -> updatePreference(AppPreferencesIntent.SetGpsInterval(intent.seconds)) + RecordingIntent.StopPreview -> stopPreview() - is RecordingIntent.SetGpsDistance -> updatePreference(AppPreferencesIntent.SetGpsMinDistance(intent.meters)) + RecordingIntent.RequestLocationPreviewPermission -> + mutableEffects.trySend(RecordingEffect.RequestLocationPreviewPermission) else -> reduce(intent) } } - fun handleRecordingArchiveResult(resultIntent: Intent?) { - val persisted = resultIntent?.let(recordingArchive::select) ?: AppResult.failure( - AppError(AppErrorCode.STORAGE, "Select recording archive"), - ) + private fun RecordingIntent.toPreferencesIntent(): AppPreferencesIntent? = when (this) { + is RecordingIntent.SetSamplingPeriod -> AppPreferencesIntent.SetSensorSamplingPeriod(index) + is RecordingIntent.SetStopOnLowBattery -> AppPreferencesIntent.SetStopOnLowBattery(enabled) + is RecordingIntent.SetWakeLock -> AppPreferencesIntent.SetWakeLock(enabled) + is RecordingIntent.SetKeepScreenAwake -> AppPreferencesIntent.SetKeepPhoneDisplayOn(enabled) + is RecordingIntent.SetGpsInterval -> AppPreferencesIntent.SetGpsInterval(seconds) + is RecordingIntent.SetGpsDistance -> AppPreferencesIntent.SetGpsMinDistance(meters) + else -> null + } + + fun handleRecordingArchiveResult(selection: RecordingArchiveSelection) { + if (selection is RecordingArchiveSelection.Cancelled) return + + val persisted = recordingArchive.select(selection as RecordingArchiveSelection.Selected) + persisted.errorOrNull()?.let(appFailures::show) mutableState.value = state.value.copy( - recordingArchivePath = recordingArchive.path().getOrNull(), + recordingArchivePath = readRecordingArchivePath(), message = if (persisted.isSuccess) RecordingMessage.NONE else RecordingMessage.RECORDING_ARCHIVE_REQUIRED, errorCode = persisted.errorOrNull()?.code, ) } + fun reportFailure(error: AppError) { + appFailures.show(error) + } + fun handlePermissionResult() { val request = state.value.toRecordingSetup() val missing = permissions.missingPermissions(request) @@ -117,9 +120,48 @@ class RecordingViewModel @Inject constructor( } } + fun handleLocationPreviewPermissionResult() { + startGpsPreview() + } + + private fun startGpsPreview() { + previewJob?.cancel() + val preferences = state.value.preferences.recording + previewJob = viewModelScope.launch { + devicePreview.observeGps( + preferences.gpsIntervalSeconds, + preferences.gpsMinDistanceMeters, + ).collect { result -> + when (result) { + is AppResult.Success -> mutableState.value = state.value.copy(gpsPreview = result.value) + is AppResult.Failure -> appFailures.show(result.error) + } + } + } + } + + private fun startSensorPreview(sensorType: Int) { + previewJob?.cancel() + previewJob = viewModelScope.launch { + devicePreview.observeSensor(sensorType).collect { result -> + when (result) { + is AppResult.Success -> mutableState.value = state.value.copy(sensorPreview = result.value) + is AppResult.Failure -> appFailures.show(result.error) + } + } + } + } + + private fun stopPreview() { + previewJob?.cancel() + previewJob = null + } + private fun initialState() = RecordingState( - sensors = availableSensors(), - recordingArchivePath = recordingArchive.path().getOrNull(), + sensors = availableSources.current.phoneSensors, + watchSensors = availableSources.current.watchSensors, + isWatchConnected = availableSources.current.isWatchConnected, + recordingArchivePath = readRecordingArchivePath(), ) private fun observePreferences() { @@ -133,10 +175,7 @@ class RecordingViewModel @Inject constructor( ) }, onFailure = { error -> - mutableState.value = state.value.copy( - message = RecordingMessage.RECORDING_FAILED, - errorCode = error.code, - ) + appFailures.show(error) }, ) } @@ -151,6 +190,14 @@ class RecordingViewModel @Inject constructor( } } + private fun observeRecordingFailures() { + viewModelScope.launch { + sessionStore.events.collect { event -> + event.result.errorOrNull()?.let(appFailures::show) + } + } + } + private fun onSessionChanged(session: RecordingSessionState) { elapsedJob?.cancel() mutableState.value = RecordingReducer.recordingSessionChanged(state.value, session) @@ -167,35 +214,22 @@ class RecordingViewModel @Inject constructor( } } - private fun observeWatchConnection() { - viewModelScope.launch { - observeWatchCapability(WEAR_APP_CAPABILITY) - .catch { error -> - AppError.from(AppErrorCode.CONNECTIVITY, "Observe Wear connection", error) - emit(WearConnection.Disconnected) - } - .collect { connection -> - if (connection is WearConnection.Connected) { - sendWatchCommand( - WEAR_APP_CAPABILITY, - WEAR_MESSAGE_PATH, - WearCommand.RequestAvailableSensors, - ) - } else { - watchSensorCatalog.clear() - } - } - } - } - - private fun observeWatchSensors() { + private fun observeAvailableSources() { viewModelScope.launch { - combine(watchSensorCatalog.sensors, watchSensorCatalog.isAvailable) { sensors, isAvailable -> - sensors to isAvailable - }.collect { (sensors, isAvailable) -> + availableSources.observe().catch { cause -> + appFailures.show( + AppError( + AppErrorCode.UNKNOWN, + "Observe available recording sources", + "Recording source observation failed", + cause, + ), + ) + }.collect { sources -> mutableState.value = state.value.copy( - isWatchConnected = isAvailable, - watchSensors = sensors.map { sensor -> sensor.toSensorDescriptor() }, + sensors = sources.phoneSensors, + isWatchConnected = sources.isWatchConnected, + watchSensors = sources.watchSensors, ) } } @@ -215,10 +249,17 @@ class RecordingViewModel @Inject constructor( showMessage(RecordingMessage.PICK_AT_LEAST_ONE_SOURCE, AppErrorCode.VALIDATION) return } - if (recordingArchive.isSelected().getOrNull() != true) { - reduce(RecordingIntent.ChooseRecordingArchive) - showMessage(RecordingMessage.RECORDING_ARCHIVE_REQUIRED, AppErrorCode.STORAGE) - return + when (val selected = recordingArchive.isSelected()) { + is AppResult.Failure -> { + appFailures.show(selected.error) + return + } + + is AppResult.Success -> if (!selected.value) { + reduce(RecordingIntent.ChooseRecordingArchive) + showMessage(RecordingMessage.RECORDING_ARCHIVE_REQUIRED, AppErrorCode.STORAGE) + return + } } requestMissingPermissions(request)?.let { mutableEffects.trySend(RecordingEffect.RequestPermissions(it)) @@ -233,6 +274,14 @@ class RecordingViewModel @Inject constructor( return missingPermissions.takeIf(Set::isNotEmpty) } + private fun readRecordingArchivePath(): String? = recordingArchive.path().fold( + onSuccess = { it }, + onFailure = { + appFailures.show(it) + null + }, + ) + private fun startRecordingAfterDelay(request: RecordingSetup, countdownSeconds: Int) { mutableState.value = RecordingReducer.recordingStartRequested(state.value, countdownSeconds) startJob = viewModelScope.launch { @@ -283,17 +332,17 @@ class RecordingViewModel @Inject constructor( } private fun showFailure(error: AppError?) { - mutableState.value = state.value.copy( - message = when { - error?.code == AppErrorCode.PERMISSION && error.context["source"] == "watch" -> - RecordingMessage.WATCH_PERMISSION_REQUIRED + when { + error == null -> appFailures.show(AppError(AppErrorCode.UNKNOWN, "Run recording operation")) - error?.code == AppErrorCode.PERMISSION -> RecordingMessage.PERMISSION_REQUIRED + error.code == AppErrorCode.PERMISSION && error.context["source"] == "watch" -> + showMessage(RecordingMessage.WATCH_PERMISSION_REQUIRED, AppErrorCode.PERMISSION) - else -> RecordingMessage.RECORDING_FAILED - }, - errorCode = error?.code ?: AppErrorCode.UNKNOWN, - ) + error.code == AppErrorCode.PERMISSION -> + showMessage(RecordingMessage.PERMISSION_REQUIRED, AppErrorCode.PERMISSION) + + else -> appFailures.show(error) + } } private fun RecordingSetup.hasAnySource(): Boolean = sensorIds.isNotEmpty() || includesGps || diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxApp.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxApp.kt index 2aa05f3..f830687 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxApp.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxApp.kt @@ -31,7 +31,7 @@ import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState @Composable fun SensorBoxApp( @@ -45,11 +45,16 @@ fun SensorBoxApp( onRecordingIntent: (RecordingIntent) -> Unit, onMeasurementBrowserIntent: (MeasurementBrowserIntent) -> Unit, onSettingsIntent: (SettingsIntent) -> Unit, + onDismissFailure: () -> Unit, ) { if (!mainState.hasLoadedPreferences) { Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) return } + mainState.visibleFailureCode?.let { failureCode -> + AppErrorScreen(failureCode, onDismissFailure) + return + } if (mainState.keepScreenAwake && mainState.session is RecordingSessionState.Running) KeepScreenAwake() SensorBoxNavHost( mainState = mainState, @@ -153,100 +158,6 @@ private fun MainRoute.isRootDestination() = when (this) { -> false } -@Composable -private fun RouteContent( - route: MainRoute, - onboardingState: OnboardingState, - recordingState: RecordingState, - measurementBrowserState: MeasurementBrowserState, - settingsState: SettingsState, - onOnboardingIntent: (OnboardingIntent) -> Unit, - onRecordingIntent: (RecordingIntent) -> Unit, - onMeasurementBrowserIntent: (MeasurementBrowserIntent) -> Unit, - onSettingsIntent: (SettingsIntent) -> Unit, - onBack: () -> Unit, -) { - when (route) { - MainRoute.ONBOARDING -> OnboardingScreen(onboardingState, onOnboardingIntent) - - MainRoute.ACTIVE_RECORDING -> FullScreen { modifier -> - ActiveRecordingScreen(recordingState, onRecordingIntent, modifier) - } - - MainRoute.SENSOR_DETAILS -> FullScreen { modifier -> - SensorDetailsScreen( - recordingState, - onBack, - { onRecordingIntent(RecordingIntent.Navigate(MainRoute.SENSOR_PREVIEW)) }, - modifier, - ) - } - - MainRoute.SENSOR_PREVIEW -> FullScreen { modifier -> - SensorPreviewScreen(state = recordingState, onBack = onBack, modifier = modifier) - } - - MainRoute.RECORDING_SETUP -> FullScreen { modifier -> - RecordingSetupScreen( - state = recordingState, - onIntent = onRecordingIntent, - modifier = modifier, - onBack = { onRecordingIntent(RecordingIntent.ReturnToSensorSelection) }, - ) - } - - MainRoute.MEASUREMENTS, - MainRoute.MEASUREMENT_DETAILS, - MainRoute.MEASUREMENT_FILE, - -> MeasurementBrowserRoute(route, measurementBrowserState, onMeasurementBrowserIntent, onBack) - - MainRoute.LICENSES -> FullScreen { modifier -> - OpenSourceLicensesScreen(onBack = onBack, modifier = modifier) - } - - MainRoute.RECORD -> FullScreen { modifier -> - RecordScreen(recordingState, onRecordingIntent, modifier) - } - - MainRoute.SETTINGS -> FullScreen { modifier -> - SettingsScreen( - state = settingsState, - onIntent = onSettingsIntent, - modifier = modifier, - onBack = onBack, - ) - } - - MainRoute.DIAGNOSTICS -> FullScreen { modifier -> - DiagnosticsLogScreen( - state = settingsState, - onBack = onBack, - onRefresh = { onSettingsIntent(SettingsIntent.ViewDiagnostics) }, - modifier = modifier, - ) - } - - MainRoute.PRIVACY -> FullScreen { modifier -> PrivacyScreen(onBack, modifier) } - } -} - -@Composable -private fun MeasurementBrowserRoute( - route: MainRoute, - state: MeasurementBrowserState, - onIntent: (MeasurementBrowserIntent) -> Unit, - onBack: () -> Unit, -) { - FullScreen { modifier -> - when (route) { - MainRoute.MEASUREMENTS -> MeasurementsScreen(state, onIntent, onBack, modifier) - MainRoute.MEASUREMENT_DETAILS -> MeasurementDetailsScreen(state, onIntent, onBack, modifier) - MainRoute.MEASUREMENT_FILE -> MeasurementFileScreen(state, onIntent, onBack, modifier) - else -> error("Not a measurement browser route") - } - } -} - private fun forwardScreenTransition(): ContentTransform = (slideInHorizontally(screenTween()) { width -> width / SCREEN_SLIDE_DIVISOR } + fadeIn(screenFadeInTween())) .togetherWith( @@ -279,7 +190,7 @@ private fun KeepScreenAwake() { } @Composable -private fun FullScreen(content: @Composable (Modifier) -> Unit) { +internal fun FullScreen(content: @Composable (Modifier) -> Unit) { Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> Box( modifier = Modifier.fillMaxSize().padding(padding), diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxButtons.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxButtons.kt new file mode 100644 index 0000000..eac7549 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxButtons.kt @@ -0,0 +1,109 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun SensorBoxPrimaryButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + loading: Boolean = false, +) { + Button( + onClick = onClick, + modifier = modifier.height(52.dp), + enabled = enabled && !loading, + shape = MaterialTheme.shapes.medium, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(10.dp)) + } + Text(label, style = MaterialTheme.typography.labelLarge) + } +} + +@Composable +fun SensorBoxDangerButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + Button( + onClick = onClick, + modifier = modifier.height(52.dp), + shape = MaterialTheme.shapes.medium, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(label, style = MaterialTheme.typography.labelLarge) + } +} + +@Composable +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, contentColor.copy(alpha = 0.72f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = contentColor), + ) { + Text(label, style = MaterialTheme.typography.labelLarge) + } +} + +@Composable +fun SensorBoxBottomButton( + title: String, + description: String, + buttonLabel: String, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface(modifier = modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surface) { + Column(Modifier.padding(horizontal = 20.dp, vertical = 14.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(title, style = MaterialTheme.typography.labelLarge) + Text(description, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.height(10.dp)) + SensorBoxPrimaryButton(buttonLabel, onClick, Modifier.fillMaxWidth(), enabled) + } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxComponents.kt deleted file mode 100644 index db492b1..0000000 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxComponents.kt +++ /dev/null @@ -1,304 +0,0 @@ -package com.tomasrepcik.sensorbox.presentation.main - -import androidx.compose.foundation.BorderStroke -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.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.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -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.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor - -@Composable -fun SensorBoxScreenHeader(title: String, subtitle: String, modifier: Modifier = Modifier) { - Column(modifier = modifier.fillMaxWidth()) { - Text(title, style = MaterialTheme.typography.headlineMedium) - Spacer(Modifier.height(6.dp)) - Text(subtitle, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyLarge) - } -} - -@Composable -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, - ) { - navigation() - Text( - text = title, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.headlineSmall, - ) - 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, - contentColor: Color = MaterialTheme.colorScheme.primary, -) { - TextButton( - onClick = onClick, - modifier = modifier.heightIn(min = 48.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), - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - Spacer(Modifier.width(8.dp)) - Text(label, style = MaterialTheme.typography.titleMedium) - } -} - -@Composable -fun SensorBoxPanel(modifier: Modifier = Modifier, content: @Composable () -> Unit) { - Surface( - modifier = modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.large, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - content = content, - ) -} - -@Composable -fun SensorBoxPrimaryButton( - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - loading: Boolean = false, -) { - Button( - onClick = onClick, - modifier = modifier.height(52.dp), - enabled = enabled && !loading, - shape = MaterialTheme.shapes.medium, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, - disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) { - if (loading) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp, - ) - Spacer(Modifier.width(10.dp)) - } - Text(label, style = MaterialTheme.typography.labelLarge) - } -} - -@Composable -fun SensorBoxDangerButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) { - Button( - onClick = onClick, - modifier = modifier.height(52.dp), - shape = MaterialTheme.shapes.medium, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Text(label, style = MaterialTheme.typography.labelLarge) - } -} - -@Composable -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, contentColor.copy(alpha = 0.72f)), - colors = ButtonDefaults.outlinedButtonColors(contentColor = contentColor), - ) { - Text(label, style = MaterialTheme.typography.labelLarge) - } -} - -@Composable -fun SensorBoxBottomButton( - title: String, - description: String, - buttonLabel: String, - enabled: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Surface( - modifier = modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surface, - ) { - Column(Modifier.padding(horizontal = 20.dp, vertical = 14.dp)) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text(title, style = MaterialTheme.typography.labelLarge) - Text(description, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Spacer(Modifier.height(10.dp)) - SensorBoxPrimaryButton(buttonLabel, onClick, Modifier.fillMaxWidth(), enabled) - } - } -} - -@Composable -fun SensorIdentity(sensor: SensorDescriptor, modifier: Modifier = Modifier) { - Image( - painter = painterResource(sensorIconResource(sensor.type)), - contentDescription = sensor.name, - modifier = modifier.size(54.dp), - ) -} - -@Composable -fun SensorSelectionIndicator(selected: Boolean) { - val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline - Box( - modifier = Modifier - .size(26.dp) - .border(2.dp, borderColor, CircleShape), - contentAlignment = Alignment.Center, - ) { - if (selected) Box(Modifier.size(12.dp).background(MaterialTheme.colorScheme.primary, CircleShape)) - } -} - -@Composable -fun SettingSummary(title: String, description: String, trailing: @Composable () -> Unit) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.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, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - Spacer(Modifier.width(14.dp)) - 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/tomasrepcik/sensorbox/presentation/main/SensorBoxRoutes.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxRoutes.kt new file mode 100644 index 0000000..3164be7 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxRoutes.kt @@ -0,0 +1,118 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.runtime.Composable + +@Composable +internal fun RouteContent( + route: MainRoute, + onboardingState: OnboardingState, + recordingState: RecordingState, + measurementBrowserState: MeasurementBrowserState, + settingsState: SettingsState, + onOnboardingIntent: (OnboardingIntent) -> Unit, + onRecordingIntent: (RecordingIntent) -> Unit, + onMeasurementBrowserIntent: (MeasurementBrowserIntent) -> Unit, + onSettingsIntent: (SettingsIntent) -> Unit, + onBack: () -> Unit, +) { + when (route) { + MainRoute.ONBOARDING -> OnboardingScreen(onboardingState, onOnboardingIntent) + + MainRoute.ACTIVE_RECORDING, + MainRoute.SENSOR_DETAILS, + MainRoute.SENSOR_PREVIEW, + MainRoute.RECORDING_SETUP, + MainRoute.RECORD, + -> RecordingRouteContent(route, recordingState, onRecordingIntent, onBack) + + MainRoute.MEASUREMENTS, + MainRoute.MEASUREMENT_DETAILS, + MainRoute.MEASUREMENT_FILE, + -> MeasurementBrowserRoute(route, measurementBrowserState, onMeasurementBrowserIntent, onBack) + + MainRoute.SETTINGS, + MainRoute.DIAGNOSTICS, + MainRoute.LICENSES, + MainRoute.PRIVACY, + -> SettingsRouteContent(route, settingsState, onSettingsIntent, onBack) + } +} + +@Composable +private fun RecordingRouteContent( + route: MainRoute, + state: RecordingState, + onIntent: (RecordingIntent) -> Unit, + onBack: () -> Unit, +) { + FullScreen { modifier -> + when (route) { + MainRoute.ACTIVE_RECORDING -> ActiveRecordingScreen(state, onIntent, modifier) + + MainRoute.SENSOR_DETAILS -> SensorDetailsScreen( + state, + onBack, + { onIntent(RecordingIntent.Navigate(MainRoute.SENSOR_PREVIEW)) }, + onIntent, + modifier, + ) + + MainRoute.SENSOR_PREVIEW -> SensorPreviewScreen(state, onIntent, onBack, modifier) + + MainRoute.RECORDING_SETUP -> RecordingSetupScreen( + state = state, + onIntent = onIntent, + modifier = modifier, + onBack = { onIntent(RecordingIntent.ReturnToSensorSelection) }, + ) + + MainRoute.RECORD -> RecordScreen(state, onIntent, modifier) + + else -> error("Not a recording route") + } + } +} + +@Composable +private fun MeasurementBrowserRoute( + route: MainRoute, + state: MeasurementBrowserState, + onIntent: (MeasurementBrowserIntent) -> Unit, + onBack: () -> Unit, +) { + FullScreen { modifier -> + when (route) { + MainRoute.MEASUREMENTS -> MeasurementsScreen(state, onIntent, onBack, modifier) + MainRoute.MEASUREMENT_DETAILS -> MeasurementDetailsScreen(state, onIntent, onBack, modifier) + MainRoute.MEASUREMENT_FILE -> MeasurementFileScreen(state, onIntent, onBack, modifier) + else -> error("Not a measurement browser route") + } + } +} + +@Composable +private fun SettingsRouteContent( + route: MainRoute, + state: SettingsState, + onIntent: (SettingsIntent) -> Unit, + onBack: () -> Unit, +) { + FullScreen { modifier -> + when (route) { + MainRoute.SETTINGS -> SettingsScreen(state, onIntent, modifier, onBack) + + MainRoute.DIAGNOSTICS -> DiagnosticsLogScreen( + state = state, + onBack = onBack, + onRefresh = { onIntent(SettingsIntent.ViewDiagnostics) }, + modifier = modifier, + ) + + MainRoute.LICENSES -> OpenSourceLicensesScreen(state, onIntent, onBack, modifier) + + MainRoute.PRIVACY -> PrivacyScreen(onBack, modifier) + + else -> error("Not a settings route") + } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxScreenComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxScreenComponents.kt new file mode 100644 index 0000000..59bca8b --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxScreenComponents.kt @@ -0,0 +1,139 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +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.material3.ButtonDefaults +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 +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.unit.Dp +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R + +@Composable +fun SensorBoxScreenHeader(title: String, subtitle: String, modifier: Modifier = Modifier) { + Column(modifier = modifier.fillMaxWidth()) { + Text(title, style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(6.dp)) + Text(subtitle, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyLarge) + } +} + +@Composable +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, + ) { + navigation() + Text(text = title, modifier = Modifier.weight(1f), style = MaterialTheme.typography.headlineSmall) + 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, + contentColor: Color = MaterialTheme.colorScheme.primary, +) { + TextButton( + onClick = onClick, + modifier = modifier.heightIn(min = 48.dp), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp), + colors = ButtonDefaults.textButtonColors(contentColor = contentColor), + ) { + Icon(painterResource(R.drawable.ic_arrow_back_24), null, Modifier.size(24.dp)) + Spacer(Modifier.width(8.dp)) + Text(label, style = MaterialTheme.typography.titleMedium) + } +} + +@Composable +fun SensorBoxPanel(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.large, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + content = content, + ) +} + +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/tomasrepcik/sensorbox/presentation/main/SensorBoxSettingsComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxSettingsComponents.kt new file mode 100644 index 0000000..75cd5a0 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorBoxSettingsComponents.kt @@ -0,0 +1,80 @@ +package com.tomasrepcik.sensorbox.presentation.main + +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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +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.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor + +@Composable +fun SensorIdentity(sensor: SensorDescriptor, modifier: Modifier = Modifier) { + Image( + painter = painterResource(sensorIconResource(sensor.type)), + contentDescription = sensor.name, + modifier = modifier.size(54.dp), + ) +} + +@Composable +fun SensorSelectionIndicator(selected: Boolean) { + val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline + Box( + modifier = Modifier.size(26.dp).border(2.dp, borderColor, CircleShape), + contentAlignment = Alignment.Center, + ) { + if (selected) Box(Modifier.size(12.dp).background(MaterialTheme.colorScheme.primary, CircleShape)) + } +} + +@Composable +fun SettingSummary(title: String, description: String, trailing: @Composable () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.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, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.width(14.dp)) + 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)) +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailComponents.kt new file mode 100644 index 0000000..0020853 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailComponents.kt @@ -0,0 +1,28 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +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.unit.dp + +@Composable +internal 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 +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/tomasrepcik/sensorbox/presentation/main/SensorDetailsContent.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsContent.kt new file mode 100644 index 0000000..3b09b12 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsContent.kt @@ -0,0 +1,62 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor + +internal fun selectedDetailsSensor(state: RecordingState): SensorDescriptor? { + val sensorType = state.detailsSensorType ?: return null + val sensors = when (state.detailsDevice) { + RecordingDevice.PHONE -> state.sensors + RecordingDevice.WATCH -> state.watchSensors + } + return sensors.firstOrNull { it.type == sensorType } +} + +@Composable +internal fun SensorDetailsList( + state: RecordingState, + sensor: SensorDescriptor?, + title: String, + showPreview: Boolean, + onIntent: (RecordingIntent) -> Unit, + onBack: () -> Unit, +) { + SensorBoxBackScreen( + title = title, + onBack = onBack, + bottomPadding = if (showPreview) 104.dp else 24.dp, + ) { + if (state.detailsSensorType == null) { + item { GpsDetails(state, onIntent) } + } else if (sensor != null) { + item { HardwareSensorDetails(sensor) } + } + } +} + +@Composable +internal fun PreviewButton(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), + ) + } + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreen.kt index 93c981f..6e509db 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorDetailsScreen.kt @@ -1,58 +1,23 @@ package com.tomasrepcik.sensorbox.presentation.main -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager import android.hardware.Sensor -import android.location.Location -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -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.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 -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import com.google.android.gms.location.LocationAvailability -import com.google.android.gms.location.LocationServices import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.core.format.ValueFormats -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor -import com.tomasrepcik.sensorbox.domain.sensors.SensorReportingMode -import com.tomasrepcik.sensorbox.sensorservices.handlers.GPSHandler @Composable fun SensorDetailsScreen( state: RecordingState, onBack: () -> Unit, onPreview: () -> Unit, + onIntent: (RecordingIntent) -> Unit, modifier: Modifier = Modifier, ) { - val sensor = state.detailsSensorType?.let { type -> - val sensors = when (state.detailsDevice) { - RecordingDevice.PHONE -> state.sensors - RecordingDevice.WATCH -> state.watchSensors - } - sensors.firstOrNull { it.type == type } - } + val sensor = selectedDetailsSensor(state) val canPreview = state.detailsSensorType == null || sensor?.type != Sensor.TYPE_STEP_DETECTOR val title = when { state.detailsSensorType == null -> stringResource(R.string.gps) @@ -62,288 +27,7 @@ fun SensorDetailsScreen( val showPreview = state.detailsDevice == RecordingDevice.PHONE && (state.detailsSensorType == null || sensor != null) && canPreview Box(modifier.fillMaxSize()) { - SensorDetailsList(state, sensor, title, showPreview, onBack) - if (showPreview) { - PreviewButton(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, - ) { - if (state.detailsSensorType == null) { - item { GpsDetails(state) } - } else if (sensor != null) { - item { - HardwareSensorDetails(sensor) - } - } + SensorDetailsList(state, sensor, title, showPreview, onIntent, onBack) + if (showPreview) PreviewButton(onPreview, Modifier.align(Alignment.BottomCenter)) } } - -@Composable -private fun HardwareSensorDetails(sensor: SensorDescriptor) { - 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, - ) - } -} - -@Composable -private fun GpsDetails(state: RecordingState) { - var permissionRevision by remember { mutableStateOf(0) } - val permissionRequest = rememberLauncherForActivityResult( - ActivityResultContracts.RequestMultiplePermissions(), - ) { - permissionRevision += 1 - } - val details = rememberGpsDetails( - intervalSeconds = state.preferences.recording.gpsIntervalSeconds, - minimumDistanceMeters = state.preferences.recording.gpsMinDistanceMeters, - permissionRevision = permissionRevision, - ) - val unavailableValue = stringResource(if (details.hasPermission) R.string.waiting else R.string.unavailable) - Column(Modifier.fillMaxWidth()) { - GpsPermissionStatus(details.hasPermission) - GpsDetailRows(details, state, unavailableValue) - if (!details.hasPermission) { - GpsPermissionButton { - permissionRequest.launch( - arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION, - ), - ) - } - } - } -} - -@Composable -private fun GpsDetailRows(details: GpsDetailsState, state: RecordingState, unavailableValue: String) { - ParameterRow( - stringResource(R.string.detail_latitude), - details.location?.latitude?.toString() ?: unavailableValue, - ) - ParameterRow( - stringResource(R.string.detail_longitude), - details.location?.longitude?.toString() ?: unavailableValue, - ) - 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, - state.preferences.recording.gpsIntervalSeconds, - state.preferences.recording.gpsIntervalSeconds, - ), - ) - ParameterRow( - stringResource(R.string.detail_minimum_distance), - pluralStringResource( - R.plurals.meters_count, - state.preferences.recording.gpsMinDistanceMeters, - state.preferences.recording.gpsMinDistanceMeters, - ), - showDivider = false, - ) -} - -private fun localizedValue(value: Number?, unavailableValue: String): String = - value?.let(::formatDecimal) ?: unavailableValue - -@Composable -internal fun GpsPermission(hasPermission: Boolean, onRequest: () -> Unit) { - GpsPermissionStatus(hasPermission) - if (!hasPermission) GpsPermissionButton(onRequest) -} - -@Composable -private fun GpsPermissionStatus(hasPermission: Boolean) { - ParameterRow( - stringResource(R.string.location_permission), - stringResource(if (hasPermission) R.string.granted else R.string.required), - ) -} - -@Composable -private fun GpsPermissionButton(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), - onClick = onRequest, - modifier = Modifier.fillMaxWidth(), - ) -} - -@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 PreviewButton(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, - minimumDistanceMeters: Int, - permissionRevision: Int, -): GpsDetailsState { - val context = LocalContext.current - val hasPermission = remember(context, permissionRevision) { context.hasLocationPermission() } - var location by remember { mutableStateOf(null) } - var isAvailable by remember { mutableStateOf(null) } - val gpsHandler = remember(context) { - GPSHandler(LocationServices.getFusedLocationProviderClient(context)) - } - - DisposableEffect(gpsHandler, hasPermission, intervalSeconds, minimumDistanceMeters) { - var active = true - if (hasPermission) { - gpsHandler.addCallback( - GpsDetailsCallback( - onLocation = { if (active && it != null) location = it }, - onAvailability = { if (active) isAvailable = it }, - ), - intervalSeconds = intervalSeconds, - minDistanceMeters = minimumDistanceMeters, - ) - } - onDispose { - active = false - gpsHandler.gpsOff() - } - } - return GpsDetailsState(hasPermission, location, isAvailable) -} - -@Composable -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) - } -} - -@Composable -private fun reportingModeLabel(mode: SensorReportingMode): String = when (mode) { - SensorReportingMode.CONTINUOUS -> stringResource(R.string.reporting_continuous) - SensorReportingMode.ON_CHANGE -> stringResource(R.string.reporting_on_change) - SensorReportingMode.ONE_SHOT -> stringResource(R.string.reporting_one_shot) - SensorReportingMode.SPECIAL_TRIGGER -> stringResource(R.string.reporting_special_trigger) - SensorReportingMode.UNKNOWN -> stringResource(R.string.unknown) -} - -@Composable -internal fun sensorUnit(type: Int): String = when (type) { - Sensor.TYPE_ACCELEROMETER, Sensor.TYPE_GRAVITY, Sensor.TYPE_LINEAR_ACCELERATION -> - stringResource(R.string.unit_acceleration) - - Sensor.TYPE_GYROSCOPE -> stringResource(R.string.unit_angular_velocity) - - Sensor.TYPE_RELATIVE_HUMIDITY -> stringResource(R.string.unit_percent) - - Sensor.TYPE_MAGNETIC_FIELD -> stringResource(R.string.unit_magnetic_field) - - Sensor.TYPE_PROXIMITY -> stringResource(R.string.unit_centimeters) - - Sensor.TYPE_PRESSURE -> stringResource(R.string.unit_pressure) - - Sensor.TYPE_LIGHT -> stringResource(R.string.unit_lux) - - Sensor.TYPE_AMBIENT_TEMPERATURE -> stringResource(R.string.unit_temperature) - - Sensor.TYPE_STEP_COUNTER, Sensor.TYPE_STEP_DETECTOR -> stringResource(R.string.unit_steps) - - else -> stringResource(R.string.unit_none) -} - -internal fun formatDecimal(value: Number): String = ValueFormats.decimal(value) - -@Composable -private fun locationAvailabilityLabel(isAvailable: Boolean?): String = when (isAvailable) { - true -> stringResource(R.string.yes) - false -> stringResource(R.string.no) - null -> stringResource(R.string.unknown) -} - -private fun Context.hasLocationPermission(): Boolean = - ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED || - ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == - PackageManager.PERMISSION_GRANTED - -internal data class GpsDetailsState(val hasPermission: Boolean, val location: Location?, val isAvailable: Boolean?) - -private class GpsDetailsCallback( - private val onLocation: (Location?) -> Unit, - private val onAvailability: (Boolean?) -> Unit, -) : GPSHandler.OnLocationChangedCallback { - override fun onLocationChanged(location: Location?) = onLocation(location) - - override fun onLastLocationSuccess(location: Location?) = onLocation(location) - - override fun onAvailabilityChanged(locationAvailability: LocationAvailability?) = - onAvailability(locationAvailability?.isLocationAvailable) -} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewComponents.kt new file mode 100644 index 0000000..f43355b --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewComponents.kt @@ -0,0 +1,28 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +internal 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 +internal fun PreviewSectionTitle(title: String) { + Text( + title, + modifier = Modifier.fillMaxWidth().padding(top = 28.dp, bottom = 12.dp), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewScreen.kt index 16cb86f..39b2766 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorPreviewScreen.kt @@ -1,55 +1,18 @@ package com.tomasrepcik.sensorbox.presentation.main -import android.Manifest -import android.graphics.Paint -import android.hardware.Sensor -import android.hardware.SensorEvent -import android.hardware.SensorEventListener -import android.hardware.SensorManager -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.Canvas -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.Row -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.shape.CircleShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.core.format.ValueFormats -import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor -import kotlin.math.max @Composable -fun SensorPreviewScreen(state: RecordingState, onBack: () -> Unit, modifier: Modifier = Modifier) { +fun SensorPreviewScreen( + state: RecordingState, + onIntent: (RecordingIntent) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { if (state.detailsDevice == RecordingDevice.WATCH) { SensorBoxBackScreen( title = stringResource(R.string.sensor_unavailable), @@ -66,388 +29,15 @@ fun SensorPreviewScreen(state: RecordingState, onBack: () -> Unit, modifier: Mod } SensorBoxBackScreen(title = title, onBack = onBack, modifier = modifier) { if (state.detailsSensorType == null) { - item { GpsPreview(state) } + item { GpsPreview(state, onIntent) } } else if (sensor != null) { - item { HardwareSensorPreview(sensor) } - } - } -} - -@Composable -private fun HardwareSensorPreview(sensor: SensorDescriptor) { - val preview = rememberHardwareSensorPreview(sensor.type) - val latestValues = preview.samples.lastOrNull()?.values - val unit = sensorUnit(sensor.type) - 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) - } - } -} - -@Composable -private fun StepCounterPreview(value: Float?) { - if (value == null) { - Text(stringResource(R.string.waiting_sensor_data), color = MaterialTheme.colorScheme.onSurfaceVariant) - return - } - Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text(value.toLong().toString(), style = MaterialTheme.typography.displayMedium) - Text(stringResource(R.string.unit_steps), color = MaterialTheme.colorScheme.onSurfaceVariant) - } -} - -@Composable -private fun SensorValues(values: FloatArray?, unit: String) { - if (values == null) { - Text(stringResource(R.string.waiting_sensor_data), color = MaterialTheme.colorScheme.onSurfaceVariant) - return - } - values.forEachIndexed { index, value -> - 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 - val colors = listOf( - MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.tertiary, - MaterialTheme.colorScheme.error, - MaterialTheme.colorScheme.secondary, - MaterialTheme.colorScheme.onSurface, - MaterialTheme.colorScheme.outline, - ) - val gridColor = MaterialTheme.colorScheme.outlineVariant - val axisColor = MaterialTheme.colorScheme.onSurfaceVariant - val validValues = samples.flatMap { sample -> - sample.values.take(axisCount).filter(Float::isFinite) - } - val scale = chartScale(validValues) - val timeAxisLabel = stringResource(R.string.chart_time_axis) - val valueAxisLabel = stringResource(R.string.chart_value_axis, unit) - - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - SensorChartCanvas(samples, axisCount, scale, colors, gridColor, axisColor, timeAxisLabel, valueAxisLabel) - if (axisCount > 0) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - repeat(axisCount) { axis -> ChartLegend(axisLabel(axis, axisCount), colors[axis]) } - } - } - } -} - -@Composable -private fun SensorChartCanvas( - samples: List, - axisCount: Int, - scale: ChartScale, - colors: List, - gridColor: Color, - axisColor: Color, - timeAxisLabel: String, - valueAxisLabel: String, -) { - Canvas(Modifier.fillMaxWidth().height(CHART_HEIGHT)) { - val plot = ChartPlot( - left = CHART_LEFT_MARGIN.toPx(), - top = CHART_TOP_MARGIN.toPx(), - right = size.width - CHART_RIGHT_MARGIN.toPx(), - bottom = size.height - CHART_BOTTOM_MARGIN.toPx(), - ) - drawChartAxes(plot, scale, samples, gridColor, axisColor, timeAxisLabel, valueAxisLabel) - if (samples.size < 2) return@Canvas - drawSensorLines(samples, axisCount, plot, scale, colors) - } -} - -private fun chartScale(values: List): ChartScale { - if (values.isEmpty()) return ChartScale(-1f, 1f) - val rawMinimum = values.minOrNull() ?: -1f - val rawMaximum = values.maxOrNull() ?: 1f - val padding = max((rawMaximum - rawMinimum) * CHART_RANGE_PADDING, MIN_CHART_RANGE / 2f) - return ChartScale(rawMinimum - padding, rawMaximum + padding) -} - -private fun DrawScope.drawChartAxes( - plot: ChartPlot, - scale: ChartScale, - samples: List, - gridColor: Color, - axisColor: Color, - timeAxisLabel: String, - valueAxisLabel: String, -) { - val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = axisColor.toArgb() - textSize = 10.sp.toPx() - } - val titlePaint = Paint(labelPaint).apply { textSize = 11.sp.toPx() } - drawValueTicks(plot, scale, gridColor, labelPaint) - drawTimeTicks(plot, samples, gridColor, labelPaint) - drawLine(axisColor, Offset(plot.left, plot.top), Offset(plot.left, plot.bottom), 1.5.dp.toPx()) - drawLine(axisColor, Offset(plot.left, plot.bottom), Offset(plot.right, plot.bottom), 1.5.dp.toPx()) - drawAxisTitles(plot, timeAxisLabel, valueAxisLabel, titlePaint) -} - -private fun DrawScope.drawValueTicks(plot: ChartPlot, scale: ChartScale, gridColor: Color, paint: Paint) { - paint.textAlign = Paint.Align.RIGHT - repeat(GRID_LINE_COUNT) { index -> - val fraction = index.toFloat() / (GRID_LINE_COUNT - 1) - val y = plot.bottom - plot.height * fraction - val value = scale.minimum + scale.range * fraction - drawLine(gridColor, Offset(plot.left, y), Offset(plot.right, y), 1.dp.toPx()) - drawIntoCanvas { canvas -> - canvas.nativeCanvas.drawText(formatChartValue(value), plot.left - 7.dp.toPx(), y + 3.dp.toPx(), paint) - } - } - if (scale.minimum < 0f && scale.maximum > 0f) { - val zeroY = plot.bottom - plot.height * (-scale.minimum / scale.range) - drawLine(gridColor, Offset(plot.left, zeroY), Offset(plot.right, zeroY), 2.dp.toPx()) - } -} - -private fun DrawScope.drawTimeTicks( - plot: ChartPlot, - samples: List, - gridColor: Color, - paint: Paint, -) { - paint.textAlign = Paint.Align.CENTER - val durationSeconds = samples.durationSeconds() - repeat(TIME_TICK_COUNT) { index -> - val fraction = index.toFloat() / (TIME_TICK_COUNT - 1) - val x = plot.left + plot.width * fraction - drawLine(gridColor, Offset(x, plot.top), Offset(x, plot.bottom), 1.dp.toPx()) - drawIntoCanvas { canvas -> - canvas.nativeCanvas.drawText( - formatChartTime(durationSeconds * fraction), - x, - plot.bottom + 14.dp.toPx(), - paint, - ) - } - } -} - -private fun DrawScope.drawAxisTitles(plot: ChartPlot, timeTitle: String, valueTitle: String, paint: Paint) { - paint.textAlign = Paint.Align.CENTER - drawIntoCanvas { canvas -> - val nativeCanvas = canvas.nativeCanvas - nativeCanvas.drawText(timeTitle, plot.left + plot.width / 2f, size.height - 2.dp.toPx(), paint) - val centerY = plot.top + plot.height / 2f - val titleX = 10.dp.toPx() - nativeCanvas.save() - nativeCanvas.rotate(-90f, titleX, centerY) - nativeCanvas.drawText(valueTitle, titleX, centerY, paint) - nativeCanvas.restore() - } -} - -private fun DrawScope.drawSensorLines( - samples: List, - axisCount: Int, - plot: ChartPlot, - scale: ChartScale, - colors: List, -) { - val firstTimestamp = samples.first().timestampNanos - val durationNanos = (samples.last().timestampNanos - firstTimestamp).coerceAtLeast(1L) - repeat(axisCount) { axis -> - val path = Path() - var hasPoint = false - samples.forEach { sample -> - val value = sample.values.getOrNull(axis)?.takeIf(Float::isFinite) ?: return@forEach - val elapsedFraction = (sample.timestampNanos - firstTimestamp).toFloat() / durationNanos - val x = plot.left + plot.width * elapsedFraction - val y = plot.bottom - plot.height * ((value - scale.minimum) / scale.range) - if (hasPoint) path.lineTo(x, y) else path.moveTo(x, y) - hasPoint = true - } - drawPath(path, colors[axis], style = Stroke(width = 2.5.dp.toPx())) - } -} - -private fun List.durationSeconds(): Float = - if (size < 2) 0f else (last().timestampNanos - first().timestampNanos) / NANOS_PER_SECOND - -private fun formatChartTime(seconds: Float): String = ValueFormats.decimal(seconds, fractionDigits = 1) - -private fun formatChartValue(value: Float): String = when { - kotlin.math.abs(value) >= 100f -> ValueFormats.decimal(value, fractionDigits = 0) - kotlin.math.abs(value) >= 10f -> ValueFormats.decimal(value, fractionDigits = 1) - else -> ValueFormats.decimal(value) -} - -@Composable -private fun ChartLegend(label: String, color: Color) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { - Box(Modifier.size(8.dp).background(color, CircleShape)) - Text(label, style = MaterialTheme.typography.labelMedium) - } -} - -@Composable -private fun GpsPreview(state: RecordingState) { - var permissionRevision by remember { mutableIntStateOf(0) } - val permissionRequest = rememberLauncherForActivityResult( - ActivityResultContracts.RequestMultiplePermissions(), - ) { - permissionRevision += 1 - } - val details = rememberGpsDetails( - intervalSeconds = state.preferences.recording.gpsIntervalSeconds, - minimumDistanceMeters = state.preferences.recording.gpsMinDistanceMeters, - permissionRevision = permissionRevision, - ) - val unavailableValue = stringResource(if (details.hasPermission) R.string.waiting else R.string.unavailable) - GpsPreviewPanel(details, unavailableValue) { - permissionRequest.launch( - arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION, - ), - ) - } -} - -@Composable -private fun GpsPreviewPanel(details: GpsDetailsState, unavailableValue: String, onRequestPermission: () -> Unit) { - 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), - ) - SensorBoxSecondaryButton( - label = stringResource(R.string.grant_location_permission), - onClick = onRequestPermission, - modifier = Modifier.fillMaxWidth(), - ) - } - } -} - -@Composable -private fun rememberHardwareSensorPreview(sensorType: Int): HardwareSensorPreviewState { - val context = LocalContext.current - val sensorManager = remember(context) { context.getSystemService(SensorManager::class.java) } - val sensor = remember(sensorManager, sensorType) { sensorManager?.getDefaultSensor(sensorType) } - var samples by remember(sensorType) { mutableStateOf(emptyList()) } - var isAvailable by remember(sensorType) { mutableStateOf(sensor != null) } - - DisposableEffect(sensorManager, sensor) { - if (sensorManager == null || sensor == null) { - isAvailable = false - onDispose { } - } else { - val listener = object : SensorEventListener { - override fun onSensorChanged(event: SensorEvent) { - val sample = TimedSensorSample(event.timestamp, event.values.copyOf()) - samples = (samples + sample).takeLast(MAX_CHART_SAMPLES) + item { + DisposableEffect(sensor.type) { + onIntent(RecordingIntent.StartSensorPreview(sensor.type)) + onDispose { onIntent(RecordingIntent.StopPreview) } } - - override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + HardwareSensorPreview(sensor, state.sensorPreview) } - isAvailable = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_UI) - onDispose { sensorManager.unregisterListener(listener) } } } - return HardwareSensorPreviewState(isAvailable, samples) -} - -@Composable -private fun axisLabel(index: Int, axisCount: Int): String = when { - axisCount == 1 -> stringResource(R.string.value) - index == 0 -> stringResource(R.string.axis_x) - index == 1 -> stringResource(R.string.axis_y) - index == 2 -> stringResource(R.string.axis_z) - index == 3 -> stringResource(R.string.axis_w) - else -> stringResource(R.string.value_number, index + 1) -} - -private data class HardwareSensorPreviewState(val isAvailable: Boolean, val samples: List) - -private data class TimedSensorSample(val timestampNanos: Long, val values: FloatArray) - -private data class ChartScale(val minimum: Float, val maximum: Float) { - val range: Float = maximum - minimum } - -private data class ChartPlot(val left: Float, val top: Float, val right: Float, val bottom: Float) { - val width: Float = right - left - val height: Float = bottom - top -} - -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 -private val CHART_BOTTOM_MARGIN = 42.dp -private const val MAX_CHART_SAMPLES = 90 -private const val MAX_CHART_AXES = 6 -private const val GRID_LINE_COUNT = 5 -private const val TIME_TICK_COUNT = 3 -private const val MIN_CHART_RANGE = 0.001f -private const val CHART_RANGE_PADDING = 0.08f -private const val NANOS_PER_SECOND = 1_000_000_000f diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorSelectionTopBar.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorSelectionTopBar.kt new file mode 100644 index 0000000..eeacc33 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SensorSelectionTopBar.kt @@ -0,0 +1,46 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R + +@Composable +internal fun SensorSelectionTopBar( + state: RecordingState, + onIntent: (RecordingIntent) -> Unit, + modifier: Modifier = Modifier, +) { + val sensorCount = selectedSourceCount(state) + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.background, + shadowElevation = 8.dp, + ) { + Box( + Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + SensorBoxPrimaryButton( + label = stringResource(R.string.continue_action), + onClick = { onIntent(RecordingIntent.OpenRecordingSetup) }, + modifier = Modifier.fillMaxWidth(), + enabled = sensorCount > 0, + ) + } + } +} + +private fun selectedSourceCount(state: RecordingState): Int = state.selectedSensorIds.size + + state.selectedWatchSensorIds.size + + (if (state.includesGps) 1 else 0) + + (if (state.watchIncludesGps) 1 else 0) + + (if (state.activityRecognition) 1 else 0) + + (if (state.significantMotion) 1 else 0) diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsComponents.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsComponents.kt new file mode 100644 index 0000000..f87c435 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsComponents.kt @@ -0,0 +1,161 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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 +import androidx.compose.material3.SwitchDefaults +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.rotate +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tomasrepcik.sensorbox.R + +@Composable +fun SamplingSetting(selected: Int, onSamplingPeriod: (Int) -> Unit) { + 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 +internal 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), + ) { + SettingsChoiceHeader(title, description) + Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) { + options.forEach { (value, label) -> + SettingsChoiceChip(label, selected == value) { onSelected(value) } + } + } + } + SensorBoxSettingsDivider() +} + +@Composable +internal fun SettingsControlRow( + 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, + ) { + SettingText(title, description, Modifier.weight(1f)) + 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 SettingsChoiceHeader(title: String, description: String) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(description, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun SettingsChoiceChip(label: String, selected: Boolean, onClick: () -> Unit) { + FilterChip( + selected = selected, + onClick = onClick, + label = { Text(label) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) +} + +@Composable +fun BooleanSetting(title: String, description: String, checked: Boolean, onChecked: (Boolean) -> Unit) { + Row( + Modifier.fillMaxWidth().padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SettingText(title, description, Modifier.weight(1f)) + Spacer(Modifier.width(14.dp)) + Switch( + checked = checked, + onCheckedChange = onChecked, + colors = SwitchDefaults.colors(checkedTrackColor = MaterialTheme.colorScheme.primary), + ) + } + SensorBoxSettingsDivider() +} + +@Composable +fun StepSetting(title: String, value: Int, valueLabel: String, minimum: Int, maximum: Int, onValue: (Int) -> Unit) { + Row(Modifier.fillMaxWidth().padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { + SettingText(title, valueLabel, Modifier.weight(1f)) + 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 +private fun SettingText(title: String, description: String, modifier: Modifier = Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(description, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun StepButton(label: String, onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + modifier = Modifier.size(40.dp), + shape = MaterialTheme.shapes.small, + contentPadding = PaddingValues(0.dp), + ) { + Text(label, style = MaterialTheme.typography.titleMedium) + } +} diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsContract.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsContract.kt index 56aecc4..ebb5612 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsContract.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsContract.kt @@ -1,14 +1,16 @@ package com.tomasrepcik.sensorbox.presentation.main -import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.preferences.AppPreferences import com.tomasrepcik.sensorbox.core.preferences.AppThemeMode +import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicense data class SettingsState( val preferences: AppPreferences = AppPreferences(), val diagnosticsText: String? = null, val diagnosticsLoaded: Boolean = false, - val errorCode: AppErrorCode? = null, + val isBatteryOptimizationExempt: Boolean = false, + val openSourceLicenses: List = emptyList(), + val selectedLicenseName: String? = null, ) sealed interface SettingsIntent { @@ -27,15 +29,18 @@ sealed interface SettingsIntent { data object CopyDiagnostics : SettingsIntent data object ClearDiagnostics : SettingsIntent data object DismissDiagnostics : SettingsIntent + data object RefreshBatteryOptimization : SettingsIntent + data class SelectOpenSourceLicense(val name: String) : SettingsIntent + data object DismissOpenSourceLicense : SettingsIntent data class Navigate(val route: MainRoute) : SettingsIntent } sealed interface SettingsEffect { data object RequestBatteryOptimizationExemption : SettingsEffect - data object ShareDiagnosticsText : SettingsEffect - data object ShareDiagnosticsFile : SettingsEffect + data class ShareDiagnosticsText(val text: String) : SettingsEffect + data class ShareDiagnosticsFile(val contentUri: String, val displayName: String, val mimeType: String) : + SettingsEffect data class CopyDiagnosticsText(val text: String) : SettingsEffect data object DiagnosticsCleared : SettingsEffect - data class DiagnosticsFailed(val code: AppErrorCode) : SettingsEffect data class Navigate(val route: MainRoute) : SettingsEffect } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsScreen.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsScreen.kt index 8e1e8ed..b909cf6 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsScreen.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsScreen.kt @@ -1,52 +1,14 @@ package com.tomasrepcik.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 -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.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -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 -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import androidx.compose.foundation.lazy.LazyListScope 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 -import androidx.compose.runtime.remember -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 -import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.LifecycleEventEffect import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.core.preferences.AppThemeMode @Composable fun SettingsScreen( @@ -55,425 +17,58 @@ fun SettingsScreen( modifier: Modifier = Modifier, onBack: () -> Unit = { onIntent(SettingsIntent.Navigate(MainRoute.RECORD)) }, ) { - val isBatteryOptimizationExempt = rememberBatteryOptimizationExemption() LaunchedEffect(Unit) { onIntent(SettingsIntent.ViewDiagnostics) } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { onIntent(SettingsIntent.RefreshBatteryOptimization) } SensorBoxBackScreen( title = stringResource(R.string.recording_settings), onBack = onBack, modifier = modifier, itemSpacing = 0.dp, ) { - item { SensorBoxSettingsSection(stringResource(R.string.settings_appearance_category)) } - item { ThemeModeSetting(state.preferences.display.themeMode, onIntent) } - item { - 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)) - } - } - item { BatteryGuardSetting(state, onIntent) } - 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) } - 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 { - SettingsControlRow( - title = stringResource(R.string.diagnostics_view), - description = stringResource(R.string.diagnostics_view_summary), - ) { onIntent(SettingsIntent.Navigate(MainRoute.DIAGNOSTICS)) } - SettingsControlRow( - title = stringResource(R.string.diagnostics_share), - description = stringResource(R.string.diagnostics_share_summary), - ) { onIntent(SettingsIntent.ShareDiagnosticsFile) } - SettingsControlRow( - title = stringResource(R.string.diagnostics_clear), - description = stringResource(R.string.diagnostics_clear_summary), - showDivider = false, - ) { confirmClear = true } - } - - if (confirmClear) { - AlertDialog( - onDismissRequest = { confirmClear = false }, - title = { Text(stringResource(R.string.diagnostics_clear)) }, - text = { Text(stringResource(R.string.diagnostics_clear_confirmation)) }, - confirmButton = { - TextButton( - onClick = { - confirmClear = false - onIntent(SettingsIntent.ClearDiagnostics) - }, - ) { - Text(stringResource(R.string.diagnostics_clear)) - } - }, - dismissButton = { - TextButton(onClick = { confirmClear = false }) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) - } -} - -@Composable -private fun GpsSettings(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { - Column { - NumberPickerSetting( - stringResource(R.string.gps_interval), - state.preferences.recording.gpsIntervalSeconds, - pluralStringResource( - R.plurals.seconds_count, - state.preferences.recording.gpsIntervalSeconds, - state.preferences.recording.gpsIntervalSeconds, - ), - 1, - 3_600, - ) { onIntent(SettingsIntent.SetGpsInterval(it)) } - NumberPickerSetting( - stringResource(R.string.gps_minimum_distance), - state.preferences.recording.gpsMinDistanceMeters, - pluralStringResource( - R.plurals.meters_count, - state.preferences.recording.gpsMinDistanceMeters, - state.preferences.recording.gpsMinDistanceMeters, - ), - 0, - 10_000, - ) { onIntent(SettingsIntent.SetGpsDistance(it)) } - } -} - -@Composable -private fun NumberPickerSetting( - title: String, - value: Int, - valueLabel: String, - minimum: Int, - maximum: Int, - onValue: (Int) -> Unit, -) { - var showPicker by rememberSaveable { mutableStateOf(false) } - - 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) { - mutableIntStateOf(value.coerceIn(minimum, maximum)) - } - AlertDialog( - onDismissRequest = { showPicker = false }, - title = { Text(title) }, - text = { - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - AndroidView( - factory = { context -> - NumberPicker(context).apply { - minValue = minimum - maxValue = maximum - wrapSelectorWheel = false - this.value = selectedValue - setOnValueChangedListener { _, _, newValue -> selectedValue = newValue } - } - }, - update = { picker -> - if (picker.value != selectedValue) picker.value = selectedValue - }, - ) - } - }, - confirmButton = { - TextButton( - onClick = { - showPicker = false - onValue(selectedValue) - }, - ) { - Text(stringResource(android.R.string.ok)) - } - }, - dismissButton = { - TextButton(onClick = { showPicker = false }) { - Text(stringResource(android.R.string.cancel)) - } - }, - ) - } -} - -@Composable -private fun BatteryGuardSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { - BooleanSetting( - title = stringResource(R.string.battery_guard), - description = stringResource(R.string.battery_guard_settings_description), - checked = state.preferences.recording.stopRecordingOnLowBattery, - ) { onIntent(SettingsIntent.SetStopOnLowBattery(it)) } -} - -@Composable -private fun CpuWakeLockSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { - BooleanSetting( - title = stringResource(R.string.keep_cpu_awake), - description = stringResource(R.string.keep_cpu_awake_settings_description), - checked = state.preferences.recording.useWakeLock, - ) { onIntent(SettingsIntent.SetWakeLock(it)) } -} - -@Composable -private fun ScreenAwakeSetting(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { - BooleanSetting( - title = stringResource(R.string.keep_screen_awake), - description = stringResource(R.string.keep_screen_awake_settings_description), - checked = state.preferences.display.keepPhoneDisplayOn, - ) { onIntent(SettingsIntent.SetKeepScreenAwake(it)) } -} - -@Composable -private fun BatteryOptimizationSetting(isExempt: Boolean, onIntent: (SettingsIntent) -> Unit) { - 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(), - ) - } + appearanceSettingsItems(state, onIntent) + recordingSettingsItems(state, onIntent) + locationSettingsItems(state, onIntent) + diagnosticsSettingsItems(state, onIntent) + appSettingsItems(onIntent) } - SensorBoxSettingsDivider() } -@Composable -private fun AboutSetting(onIntent: (SettingsIntent) -> Unit) { - var showAboutDialog by rememberSaveable { mutableStateOf(false) } - - SettingsControlRow( - title = stringResource(R.string.menu_about), - description = stringResource(R.string.about_summary), - showDivider = false, - showChevron = true, - ) { showAboutDialog = true } - - if (showAboutDialog) { - AboutDialog( - onDismiss = { showAboutDialog = false }, - onPrivacy = { - showAboutDialog = false - onIntent(SettingsIntent.Navigate(MainRoute.PRIVACY)) - }, - onLicenses = { - showAboutDialog = false - onIntent(SettingsIntent.Navigate(MainRoute.LICENSES)) - }, - ) +private fun LazyListScope.appearanceSettingsItems(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { + item { SensorBoxSettingsSection(stringResource(R.string.settings_appearance_category)) } + item { ThemeModeSetting(state.preferences.display.themeMode, onIntent) } + item { + BooleanSetting( + title = stringResource(R.string.dynamic_colors), + description = stringResource(R.string.dynamic_colors_description), + checked = state.preferences.display.dynamicColors, + ) { onIntent(SettingsIntent.SetDynamicColors(it)) } } } -@Composable -private fun rememberBatteryOptimizationExemption(): Boolean { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - var isExempt by remember(context) { mutableStateOf(context.isBatteryOptimizationExempt()) } - - DisposableEffect(context, lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) isExempt = context.isBatteryOptimizationExempt() +private fun LazyListScope.recordingSettingsItems(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { + item { SensorBoxSettingsSection(stringResource(R.string.settings_recording_category)) } + item { + SamplingSetting(state.preferences.recording.sensorSamplingPeriod) { index -> + onIntent(SettingsIntent.SetSamplingPeriod(index)) } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } - return isExempt -} - -private fun Context.isBatteryOptimizationExempt(): Boolean = - getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(packageName) - -@Composable -fun SamplingSetting(selected: Int, onSamplingPeriod: (Int) -> Unit) { - 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, - ) + item { BatteryGuardSetting(state, onIntent) } + item { BatteryOptimizationSetting(state.isBatteryOptimizationExempt, onIntent) } + item { CpuWakeLockSetting(state, onIntent) } + item { ScreenAwakeSetting(state, onIntent) } } -@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() +private fun LazyListScope.locationSettingsItems(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { + item { SensorBoxSettingsSection(stringResource(R.string.settings_location_category)) } + item { GpsSettings(state, onIntent) } } -@Composable -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() +private fun LazyListScope.diagnosticsSettingsItems(state: SettingsState, onIntent: (SettingsIntent) -> Unit) { + item { SensorBoxSettingsSection(stringResource(R.string.diagnostics_title)) } + item { DiagnosticsSetting(state, onIntent) } } -@Composable -private fun SettingsControlRow( - 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, - label = { Text(label) }, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, - selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - ) -} - -@Composable -fun StepSetting(title: String, value: Int, valueLabel: String, minimum: Int, maximum: Int, onValue: (Int) -> 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(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 -private fun StepButton(label: String, onClick: () -> Unit) { - OutlinedButton( - onClick = onClick, - modifier = Modifier.size(40.dp), - shape = MaterialTheme.shapes.small, - contentPadding = PaddingValues(0.dp), - ) { - Text(label, style = MaterialTheme.typography.titleMedium) - } +private fun LazyListScope.appSettingsItems(onIntent: (SettingsIntent) -> Unit) { + item { SensorBoxSettingsSection(stringResource(R.string.settings_app_category)) } + item { AboutSetting(onIntent) } } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsViewModel.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsViewModel.kt index f86f7be..af78b03 100644 --- a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsViewModel.kt +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/SettingsViewModel.kt @@ -2,12 +2,16 @@ package com.tomasrepcik.sensorbox.presentation.main import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppError +import com.tomasrepcik.sensorbox.core.error.AppFailureStore import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.error.DiagnosticsStore import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesIntent import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository import com.tomasrepcik.sensorbox.di.IoDispatcher +import com.tomasrepcik.sensorbox.domain.diagnostics.DiagnosticsShareFilePreparer +import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicenseRepository +import com.tomasrepcik.sensorbox.domain.preview.DevicePreviewRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.channels.Channel @@ -23,6 +27,10 @@ class SettingsViewModel @Inject constructor( private val preferencesRepository: AppPreferencesRepository, private val diagnosticsStore: DiagnosticsStore, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val appFailures: AppFailureStore, + private val devicePreview: DevicePreviewRepository, + private val licenses: OpenSourceLicenseRepository, + private val diagnosticsShareFile: DiagnosticsShareFilePreparer, ) : ViewModel() { private val mutableState = MutableStateFlow(SettingsState()) private val mutableEffects = Channel(Channel.BUFFERED) @@ -31,15 +39,15 @@ class SettingsViewModel @Inject constructor( val effects = mutableEffects.receiveAsFlow() init { + loadLicenses() viewModelScope.launch { preferencesRepository.preferences.collect { result -> when (result) { is AppResult.Success -> mutableState.value = state.value.copy( preferences = result.value, - errorCode = null, ) - is AppResult.Failure -> mutableState.value = state.value.copy(errorCode = result.error.code) + is AppResult.Failure -> appFailures.show(result.error) } } } @@ -50,6 +58,7 @@ class SettingsViewModel @Inject constructor( update(it) return } + if (handleStateIntent(intent)) return when (intent) { SettingsIntent.RequestBatteryOptimizationExemption -> mutableEffects.trySend(SettingsEffect.RequestBatteryOptimizationExemption) @@ -64,10 +73,11 @@ class SettingsViewModel @Inject constructor( SettingsIntent.ClearDiagnostics -> clearDiagnostics() - SettingsIntent.DismissDiagnostics -> mutableState.value = state.value.copy(diagnosticsText = null) - - is SettingsIntent.Navigate -> mutableEffects.trySend(SettingsEffect.Navigate(intent.route)) - + SettingsIntent.DismissDiagnostics, + SettingsIntent.RefreshBatteryOptimization, + is SettingsIntent.SelectOpenSourceLicense, + SettingsIntent.DismissOpenSourceLicense, + is SettingsIntent.Navigate, is SettingsIntent.SetSamplingPeriod, is SettingsIntent.SetStopOnLowBattery, is SettingsIntent.SetWakeLock, @@ -80,6 +90,41 @@ class SettingsViewModel @Inject constructor( } } + private fun handleStateIntent(intent: SettingsIntent): Boolean { + when (intent) { + SettingsIntent.DismissDiagnostics -> mutableState.value = state.value.copy(diagnosticsText = null) + + SettingsIntent.RefreshBatteryOptimization -> refreshBatteryOptimization() + + is SettingsIntent.SelectOpenSourceLicense -> + mutableState.value = state.value.copy(selectedLicenseName = intent.name) + + SettingsIntent.DismissOpenSourceLicense -> + mutableState.value = state.value.copy(selectedLicenseName = null) + + is SettingsIntent.Navigate -> mutableEffects.trySend(SettingsEffect.Navigate(intent.route)) + + else -> return false + } + return true + } + + private fun refreshBatteryOptimization() { + when (val result = devicePreview.batteryOptimizationExemption()) { + is AppResult.Success -> mutableState.value = state.value.copy(isBatteryOptimizationExempt = result.value) + is AppResult.Failure -> fail(result.error) + } + } + + private fun loadLicenses() { + viewModelScope.launch(ioDispatcher) { + when (val result = licenses.load()) { + is AppResult.Success -> mutableState.value = state.value.copy(openSourceLicenses = result.value) + is AppResult.Failure -> fail(result.error) + } + } + } + private fun SettingsIntent.toPreferencesIntent(): AppPreferencesIntent? = when (this) { is SettingsIntent.SetSamplingPeriod -> AppPreferencesIntent.SetSensorSamplingPeriod(index) is SettingsIntent.SetStopOnLowBattery -> AppPreferencesIntent.SetStopOnLowBattery(enabled) @@ -95,7 +140,7 @@ class SettingsViewModel @Inject constructor( private fun update(intent: AppPreferencesIntent) { viewModelScope.launch { preferencesRepository.dispatch(intent).onFailure { error -> - mutableState.value = state.value.copy(errorCode = error.code) + appFailures.show(error) } } } @@ -106,35 +151,45 @@ class SettingsViewModel @Inject constructor( is AppResult.Success -> mutableState.value = state.value.copy( diagnosticsText = result.value, diagnosticsLoaded = true, - errorCode = null, ) - is AppResult.Failure -> fail(result.error.code) + is AppResult.Failure -> fail(result.error) } } } private fun shareDiagnosticsText() { - withDiagnostics { mutableEffects.send(SettingsEffect.ShareDiagnosticsText) } + withDiagnostics { text -> mutableEffects.send(SettingsEffect.ShareDiagnosticsText(text)) } } private fun shareDiagnosticsFile() { - withDiagnostics { mutableEffects.send(SettingsEffect.ShareDiagnosticsFile) } + withDiagnostics { text -> + when (val result = diagnosticsShareFile.prepare(text)) { + is AppResult.Success -> mutableEffects.send( + SettingsEffect.ShareDiagnosticsFile( + result.value.contentUri, + result.value.displayName, + result.value.mimeType, + ), + ) + + is AppResult.Failure -> fail(result.error) + } + } } - private fun withDiagnostics(operation: suspend () -> Unit) { + private fun withDiagnostics(operation: suspend (String) -> 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()) operation() + if (result.value.isNotBlank()) operation(result.value) } - is AppResult.Failure -> fail(result.error.code) + is AppResult.Failure -> fail(result.error) } } } @@ -143,7 +198,7 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch(ioDispatcher) { when (val result = diagnosticsStore.readText()) { is AppResult.Success -> mutableEffects.send(SettingsEffect.CopyDiagnosticsText(result.value)) - is AppResult.Failure -> fail(result.error.code) + is AppResult.Failure -> fail(result.error) } } } @@ -155,18 +210,20 @@ class SettingsViewModel @Inject constructor( mutableState.value = state.value.copy( diagnosticsText = "", diagnosticsLoaded = true, - errorCode = null, ) mutableEffects.send(SettingsEffect.DiagnosticsCleared) } - is AppResult.Failure -> fail(result.error.code) + is AppResult.Failure -> fail(result.error) } } } - private suspend fun fail(code: AppErrorCode) { - mutableState.value = state.value.copy(errorCode = code) - mutableEffects.send(SettingsEffect.DiagnosticsFailed(code)) + private fun fail(error: AppError) { + appFailures.show(error) + } + + fun reportFailure(error: AppError) { + fail(error) } } diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/StoredSensorChart.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/StoredSensorChart.kt new file mode 100644 index 0000000..4bec641 --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/StoredSensorChart.kt @@ -0,0 +1,242 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import android.graphics.Paint +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.core.format.ValueFormats +import com.tomasrepcik.sensorbox.domain.measurements.SensorSeriesSample +import kotlin.math.max + +@Composable +internal fun StoredSensorChart( + columns: List, + samples: List, + timeWindow: SensorChartTimeWindow, + onIntent: (MeasurementBrowserIntent) -> Unit, +) { + if (samples.isEmpty() || columns.isEmpty()) { + Text(stringResource(R.string.no_samples), color = MaterialTheme.colorScheme.onSurfaceVariant) + return + } + val colors = storedChartColors() + val viewport = rememberStoredChartViewport(samples, timeWindow) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + StoredChartPlot(viewport, columns.size, colors, onIntent) + StoredChartControls(onIntent) + StoredChartTimeSummary(viewport) + StoredChartLegend(columns, colors) + } +} + +@Composable +private fun storedChartColors(): List = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary, + MaterialTheme.colorScheme.error, + MaterialTheme.colorScheme.secondary, +) + +@Composable +private fun rememberStoredChartViewport( + samples: List, + timeWindow: SensorChartTimeWindow, +): StoredChartViewport { + val orderedSamples = remember(samples) { samples.sortedBy(SensorSeriesSample::timestampMillis) } + val visibleSamples = remember(orderedSamples, timeWindow) { + selectSensorSamplesInTimeWindow(orderedSamples, timeWindow) + } + val firstTimestamp = orderedSamples.first().timestampMillis + val fullDuration = (orderedSamples.last().timestampMillis - firstTimestamp).coerceAtLeast(0L) + return StoredChartViewport( + samples = visibleSamples, + fullDurationMillis = fullDuration, + visibleDurationMillis = (fullDuration * timeWindow.visibleFraction).toLong().coerceAtLeast(0L), + visibleStartTimestamp = firstTimestamp + (fullDuration * timeWindow.startFraction).toLong(), + visibleEndTimestamp = firstTimestamp + (fullDuration * timeWindow.endFraction).toLong(), + ) +} + +@Composable +private fun StoredChartPlot( + viewport: StoredChartViewport, + columnCount: Int, + colors: List, + onIntent: (MeasurementBrowserIntent) -> Unit, +) { + Canvas( + Modifier + .fillMaxWidth() + .height(260.dp) + .background(MaterialTheme.colorScheme.surfaceVariant) + .pointerInput(Unit) { + detectTransformGestures { centroid, pan, zoom, _ -> + val chartWidth = size.width.coerceAtLeast(1).toFloat() + if (zoom != 1f) { + onIntent( + MeasurementBrowserIntent.ZoomSensorChartTimeWindow( + zoomFactor = zoom, + focalPointFraction = centroid.x / chartWidth, + ), + ) + } + if (pan.x != 0f) { + onIntent( + MeasurementBrowserIntent.ShiftSensorChartTimeWindow( + visibleWindowFraction = -pan.x / chartWidth, + ), + ) + } + } + }, + ) { + drawStoredChart(viewport, columnCount, colors) + } +} + +@Composable +private fun StoredChartControls(onIntent: (MeasurementBrowserIntent) -> Unit) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + TextButton(onClick = { onIntent(MeasurementBrowserIntent.ZoomSensorChartTimeWindow(0.5f, 0.5f)) }) { + Text(stringResource(R.string.sensor_chart_zoom_out)) + } + TextButton(onClick = { onIntent(MeasurementBrowserIntent.ShowEntireSensorChartTimeRange) }) { + Text(stringResource(R.string.sensor_chart_show_all)) + } + TextButton(onClick = { onIntent(MeasurementBrowserIntent.ZoomSensorChartTimeWindow(2f, 0.5f)) }) { + Text(stringResource(R.string.sensor_chart_zoom_in)) + } + } +} + +@Composable +private fun StoredChartTimeSummary(viewport: StoredChartViewport) { + Text( + stringResource( + R.string.sensor_chart_visible_duration, + formatSensorChartDuration(viewport.visibleDurationMillis), + formatSensorChartDuration(viewport.fullDurationMillis), + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + Text( + stringResource(R.string.sensor_chart_gesture_hint), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) +} + +@Composable +private fun StoredChartLegend(columns: List, colors: List) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + columns.forEachIndexed { index, column -> + Row { + Spacer(Modifier.size(10.dp).background(colors[index % colors.size])) + Spacer(Modifier.size(4.dp)) + Text(column, style = MaterialTheme.typography.bodySmall) + } + } + } +} + +private fun DrawScope.drawStoredChart(viewport: StoredChartViewport, columnCount: Int, colors: List) { + val values = viewport.samples.flatMap(SensorSeriesSample::values) + val minimum = values.minOrNull() ?: 0.0 + val maximum = values.maxOrNull() ?: 1.0 + val range = max(maximum - minimum, MINIMUM_CHART_RANGE) + val widthAvailable = size.width - CHART_LABEL_WIDTH + val heightAvailable = size.height - CHART_BOTTOM_PADDING + drawStoredChartGrid(maximum, range, heightAvailable) + drawStoredChartLines( + viewport = viewport, + columnCount = columnCount, + colors = colors, + maximum = maximum, + range = range, + widthAvailable = widthAvailable, + heightAvailable = heightAvailable, + ) +} + +private fun DrawScope.drawStoredChartGrid(maximum: Double, range: Double, heightAvailable: Float) { + repeat(CHART_GRID_LINES + 1) { line -> + val y = heightAvailable * line / CHART_GRID_LINES + drawLine(Color.Gray.copy(alpha = 0.25f), Offset(CHART_LABEL_WIDTH, y), Offset(size.width, y)) + val value = maximum - range * line / CHART_GRID_LINES + drawIntoCanvas { canvas -> + val paint = Paint().apply { + color = Color.Gray.toArgb() + textSize = 10.sp.toPx() + } + canvas.nativeCanvas.drawText(ValueFormats.decimal(value.toFloat()), 4.dp.toPx(), y + 4.dp.toPx(), paint) + } + } +} + +private fun DrawScope.drawStoredChartLines( + viewport: StoredChartViewport, + columnCount: Int, + colors: List, + maximum: Double, + range: Double, + widthAvailable: Float, + heightAvailable: Float, +) { + val timestampRange = (viewport.visibleEndTimestamp - viewport.visibleStartTimestamp).coerceAtLeast(1L) + clipRect(left = CHART_LABEL_WIDTH, top = 0f, right = size.width, bottom = heightAvailable) { + repeat(columnCount) { column -> + val path = Path() + var hasPoint = false + viewport.samples.forEach { sample -> + val value = sample.values.getOrNull(column) ?: return@forEach + val elapsed = (sample.timestampMillis - viewport.visibleStartTimestamp).toFloat() / timestampRange + val x = CHART_LABEL_WIDTH + widthAvailable * elapsed + val y = ((maximum - value) / range * heightAvailable).toFloat() + if (hasPoint) path.lineTo(x, y) else path.moveTo(x, y) + hasPoint = true + } + drawPath(path, colors[column % colors.size], style = Stroke(width = 2.dp.toPx())) + } + } +} + +private data class StoredChartViewport( + val samples: List, + val fullDurationMillis: Long, + val visibleDurationMillis: Long, + val visibleStartTimestamp: Long, + val visibleEndTimestamp: Long, +) + +private const val MINIMUM_CHART_RANGE = 0.0001 +private const val CHART_LABEL_WIDTH = 52f +private const val CHART_BOTTOM_PADDING = 12f +private const val CHART_GRID_LINES = 4 diff --git a/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ThemeModeSetting.kt b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ThemeModeSetting.kt new file mode 100644 index 0000000..c0e4d4b --- /dev/null +++ b/app/src/main/java/com/tomasrepcik/sensorbox/presentation/main/ThemeModeSetting.kt @@ -0,0 +1,20 @@ +package com.tomasrepcik.sensorbox.presentation.main + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.core.preferences.AppThemeMode + +@Composable +internal 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)) } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 93a1c96..97cc84f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,6 +5,15 @@ Agree and continue Back Close + Something went wrong + The device connection failed. The error was saved in diagnostics. + The requested permission could not be used. The error was saved in diagnostics. + Settings could not be loaded or saved. The error was saved in diagnostics. + The recording operation failed. The error was saved in diagnostics. + Local storage could not be read or updated. The error was saved in diagnostics. + Android could not complete the requested action. The error was saved in diagnostics. + The operation did not finish in time. The error was saved in diagnostics. + The operation failed. The error was saved in diagnostics. Yes No On @@ -119,6 +128,7 @@ Started %1$d data files Sensor samples + Sensor details GPS coordinates Text data Loading measurements… diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/data/measurements/MeasurementMetadataParserTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/data/measurements/MeasurementMetadataParserTest.kt new file mode 100644 index 0000000..6684f53 --- /dev/null +++ b/app/src/test/java/com/tomasrepcik/sensorbox/data/measurements/MeasurementMetadataParserTest.kt @@ -0,0 +1,63 @@ +package com.tomasrepcik.sensorbox.data.measurements + +import com.tomasrepcik.sensorbox.domain.measurements.MeasurementMetadataEntry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class MeasurementMetadataParserTest { + @Test + fun `Given several sensors When metadata is parsed Then sensor details leave the session summary`() { + // Given + val metadata = """ + { + "folder": "Morning walk", + "stopReason": "DURATION_EXPIRED", + "ranges": [ + {"sensor": "Bosch accelerometer", "type": 1, "range": 78.4}, + {"sensor": "Bosch gyroscope", "type": 4, "range": 34.9} + ], + "sensorFiles": [ + {"fileName": "accelerometer.csv", "writtenSamples": 120, "droppedSamples": 0}, + {"fileName": "gyroscope.csv", "writtenSamples": 118, "droppedSamples": 2} + ] + } + """.trimIndent() + + // When + val parsed = MeasurementMetadataParser.parse(metadata) + + // Then + assertEquals( + listOf( + MeasurementMetadataEntry("folder", "Morning walk"), + MeasurementMetadataEntry("stopReason", "DURATION_EXPIRED"), + ), + parsed.session, + ) + assertFalse(parsed.session.any { entry -> entry.name.startsWith("ranges") }) + assertEquals( + listOf( + MeasurementMetadataEntry("sensor", "Bosch accelerometer"), + MeasurementMetadataEntry("type", "1"), + MeasurementMetadataEntry("range", "78.4"), + MeasurementMetadataEntry("writtenSamples", "120"), + MeasurementMetadataEntry("droppedSamples", "0"), + ), + parsed.bySensorFile.getValue("accelerometer.csv"), + ) + assertEquals("Bosch gyroscope", parsed.bySensorFile.getValue("gyroscope.csv").first().value) + } + + @Test + fun `Given no metadata When parsed Then both detail groups are empty`() { + // Given + val metadata = "" + + // When + val parsed = MeasurementMetadataParser.parse(metadata) + + // Then + assertEquals(ParsedMeasurementMetadata.EMPTY, parsed) + } +} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt deleted file mode 100644 index 126848c..0000000 --- a/app/src/test/java/com/tomasrepcik/sensorbox/domain/paired/PairedRecordingCoordinatorTest.kt +++ /dev/null @@ -1,267 +0,0 @@ -package com.tomasrepcik.sensorbox.domain.paired - -import com.tomasrepcik.sensorbox.core.error.AppError -import com.tomasrepcik.sensorbox.core.error.AppErrorCode -import com.tomasrepcik.sensorbox.core.error.AppResult -import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger -import com.tomasrepcik.sensorbox.domain.recording.PhoneRecordingController -import com.tomasrepcik.sensorbox.domain.recording.RecordingSetup -import com.tomasrepcik.sensorbox.domain.recording.StartedPhoneRecording -import com.tomasrepcik.sensorbox.wearoslib.connectivity.SendWearMessageUseCase -import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnection -import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnectionRepository -import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearNode -import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOperation -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearRecordingOutcome -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class PairedRecordingCoordinatorTest { - @Test - fun `Given phone sources only When recording starts Then no watch command is sent`() = runTest { - // Given - val fixture = Fixture() - - // When - val result = fixture.coordinator.start(request()) - - // Then - assertTrue(result.isSuccess) - assertEquals(1, fixture.phone.startCalls) - assertTrue(fixture.repository.commands.isEmpty()) - } - - @Test - fun `Given watch sources When recording starts Then phone starts before direct watch start`() = runTest { - // Given - val fixture = Fixture() - - // When - val result = fixture.coordinator.start(request(watchSensorIds = setOf(1))) - - // Then - assertTrue(result.isSuccess) - assertEquals(1, fixture.phone.startCalls) - assertTrue(fixture.repository.commands.single() is WearCommand.StartRecording) - } - - @Test - fun `Given timed paired recording When recording starts Then both devices receive the same duration`() = runTest { - // Given - val fixture = Fixture() - - // When - val result = fixture.coordinator.start( - request(watchSensorIds = setOf(1), durationSeconds = 12), - ) - - // Then - val watchStart = fixture.repository.commands.single() as WearCommand.StartRecording - assertTrue(result.isSuccess) - assertEquals(12_000L, fixture.phone.startedDurationMillis) - assertEquals(fixture.phone.startedDurationMillis, watchStart.request.durationMillis) - } - - @Test - fun `Given watch start fails When recording starts Then phone and watch are stopped`() = runTest { - // Given - val fixture = Fixture().apply { repository.failStart = true } - - // When - val result = fixture.coordinator.start(request(watchSensorIds = setOf(1))) - - // Then - assertTrue(result.isFailure) - assertEquals(1, fixture.phone.stopCalls) - assertTrue(fixture.repository.commands.last() is WearCommand.StopRecording) - } - - @Test - fun `Given watch start result is lost When recording starts Then phone keeps recording`() = runTest { - // Given - val fixture = Fixture().apply { repository.dropStartResult = true } - - // When - val result = fixture.coordinator.start( - request(watchSensorIds = setOf(1), durationSeconds = 12), - ) - - // Then - assertTrue(result.isSuccess) - assertEquals(1, fixture.phone.startCalls) - assertEquals(0, fixture.phone.stopCalls) - assertEquals(3, fixture.repository.commands.count { it is WearCommand.StartRecording }) - } - - @Test - fun `Given paired recording When stopped Then phone and watch receive stop`() = runTest { - // Given - val fixture = Fixture() - fixture.coordinator.start(request(watchSensorIds = setOf(1))) - - // When - val result = fixture.coordinator.stop() - - // Then - assertTrue(result.isSuccess) - assertEquals(1, fixture.phone.stopCalls) - assertTrue(fixture.repository.commands.last() is WearCommand.StopRecording) - } - - @Test - fun `Given active recording When another start is requested Then intent is rejected`() = runTest { - // Given - val fixture = Fixture() - fixture.coordinator.start(request()) - - // When - val result = fixture.coordinator.start(request()) - - // Then - assertTrue(result.isFailure) - assertEquals(AppErrorCode.CONFLICT, result.errorOrNull()?.code) - assertEquals(1, fixture.phone.startCalls) - } - - @Test - fun `Given active recording When stale watch stop arrives Then phone keeps recording`() = runTest { - // Given - val fixture = Fixture() - fixture.coordinator.start(request()) - - // When - val result = fixture.coordinator.stopFromWatch("stale-session", WearStopReason.USER_REQUEST) - - // Then - assertTrue(result.isSuccess) - assertEquals(0, fixture.phone.stopCalls) - } - - @Test - fun `Given phone timer expires while watch is unreachable When recording starts again Then phone is available`() = - runTest { - // Given - val fixture = Fixture() - fixture.coordinator.start(request(watchSensorIds = setOf(1), durationSeconds = 12)) - val sessionId = (fixture.repository.commands.single() as WearCommand.StartRecording).sessionId - fixture.repository.failSends = true - - // When - val peerNotification = fixture.coordinator.onAutomaticPhoneStop( - sessionId, - WearStopReason.DURATION_EXPIRED, - ) - fixture.repository.failSends = false - val nextStart = fixture.coordinator.start(request()) - - // Then - assertTrue(peerNotification.isFailure) - assertTrue(nextStart.isSuccess) - assertEquals(0, fixture.phone.stopCalls) - assertEquals(2, fixture.phone.startCalls) - } - - private fun request(watchSensorIds: Set = emptySet(), durationSeconds: Int = 0) = RecordingSetup( - sensorIds = setOf(1), - includesGps = false, - samplingPeriodIndex = 0, - stopOnLowBattery = false, - useWakeLock = false, - gpsIntervalSeconds = 10, - gpsMinDistanceMeters = 20, - watchSensorIds = watchSensorIds, - durationSeconds = durationSeconds, - ) - - private class Fixture { - val inbox = WatchRecordingResultInbox() - val phone = FakePhoneRecordingController() - val repository = RecordingResultRepository(inbox) - val coordinator = PairedRecordingCoordinator( - phoneRecording = phone, - sendCommand = SendWearCommandUseCase(SendWearMessageUseCase(repository)), - watchResults = inbox, - diagnosticLogger = DiagnosticLogger { }, - ) - } -} - -private class FakePhoneRecordingController : PhoneRecordingController { - var startCalls = 0 - var stopCalls = 0 - var startedDurationMillis = 0L - - override fun start(sessionId: String, request: RecordingSetup): AppResult { - startCalls += 1 - startedDurationMillis = request.durationSeconds.coerceAtLeast(0) * 1_000L - return AppResult.success(StartedPhoneRecording(sessionId, "fixture", startedDurationMillis)) - } - - override fun stop(sessionId: String, reason: WearStopReason): AppResult { - stopCalls += 1 - return AppResult.success(Unit) - } - - override fun stopCurrent(reason: WearStopReason): AppResult { - stopCalls += 1 - return AppResult.success(Unit) - } - - override fun annotate(text: String, timestampMillis: Long): AppResult = AppResult.success(Unit) -} - -private class RecordingResultRepository(private val inbox: WatchRecordingResultInbox) : WearConnectionRepository { - val commands = mutableListOf() - var failStart = false - var failSends = false - var dropStartResult = false - - override fun observeCapability(capability: String): Flow = emptyFlow() - - override suspend fun findNode(capability: String): WearNode? = WearNode("watch", "Watch", isNearby = true) - - override suspend fun sendMessage(capability: String, path: String, payload: ByteArray): AppResult { - if (failSends) { - return AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Send Wear command")) - } - - val command = WearCommandCodec.decode(payload).getOrThrow() - commands += command - when (command) { - is WearCommand.StartRecording -> if (!dropStartResult) { - publish( - command.sessionId, - WearRecordingOperation.START, - failed = failStart, - ) - } - - is WearCommand.StopRecording -> publish(command.sessionId, WearRecordingOperation.STOP, failed = false) - - else -> Unit - } - return AppResult.success(Unit) - } - - private fun publish(sessionId: String, operation: WearRecordingOperation, failed: Boolean) { - inbox.publish( - WearCommand.RecordingResult( - sessionId = sessionId, - operation = operation, - outcome = if (failed) WearRecordingOutcome.FAILED else WearRecordingOutcome.SUCCEEDED, - errorCode = AppErrorCode.RECORDING.takeIf { failed }, - errorOperation = "Start watch recording".takeIf { failed }, - errorMessage = "Fixture start failed".takeIf { failed }, - failureCount = if (failed) 1 else 0, - ), - ) - } -} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/DocumentRecordingArchiveRepositoryTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/DocumentRecordingArchiveRepositoryTest.kt index f46b004..27ab0ff 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/DocumentRecordingArchiveRepositoryTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/DocumentRecordingArchiveRepositoryTest.kt @@ -1,10 +1,8 @@ package com.tomasrepcik.sensorbox.domain.recording -import android.content.Intent import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.storage.DocumentStorage import org.junit.Assert.assertEquals -import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test import java.io.InputStream @@ -15,19 +13,22 @@ class DocumentRecordingArchiveRepositoryTest { fun `Given document storage When recording archive operations run Then each result is returned`() { val storage = FakeDocumentStorage() val repository = DocumentRecordingArchiveRepository(storage) - val intent = Intent() + val selection = RecordingArchiveSelection.Selected("content://fixture", 3) assertTrue(repository.isSelected().getOrNull() == true) assertEquals("Documents/SensorBox", repository.path().getOrNull()) - assertTrue(repository.select(intent).isSuccess) - assertSame(intent, storage.persistedIntent) + assertTrue(repository.select(selection).isSuccess) + assertEquals(selection.uri, storage.persistedUri) + assertEquals(selection.grantFlags, storage.persistedFlags) } private class FakeDocumentStorage : DocumentStorage { - var persistedIntent: Intent? = null + var persistedUri: String? = null + var persistedFlags: Int? = null - override fun persistRootAccess(intent: Intent): AppResult { - persistedIntent = intent + override fun persistRootAccess(uri: String, grantFlags: Int): AppResult { + persistedUri = uri + persistedFlags = grantFlags return AppResult.success(Unit) } diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/RecordingControlUseCaseTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/RecordingControlUseCaseTest.kt new file mode 100644 index 0000000..7c208f1 --- /dev/null +++ b/app/src/test/java/com/tomasrepcik/sensorbox/domain/recording/RecordingControlUseCaseTest.kt @@ -0,0 +1,245 @@ +package com.tomasrepcik.sensorbox.domain.recording + +import com.tomasrepcik.sensorbox.core.error.AppError +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger +import com.tomasrepcik.sensorbox.core.time.EpochClock +import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingCommandSender +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearStopReason +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecordingControlUseCaseTest { + @Test + fun `Given phone sources only When recording starts Then no watch command is sent`() = runTest { + // Given + val fixture = Fixture() + + // When + val result = fixture.user.start(request()) + + // Then + assertTrue(result.isSuccess) + assertEquals(1, fixture.phone.startCalls) + assertTrue(fixture.watch.commands.isEmpty()) + } + + @Test + fun `Given watch sources When recording starts Then phone starts before direct watch start`() = runTest { + // Given + val fixture = Fixture() + + // When + val result = fixture.user.start(request(watchSensorIds = setOf(1))) + + // Then + assertTrue(result.isSuccess) + assertEquals(1, fixture.phone.startCalls) + assertTrue(fixture.watch.commands.single() is WearCommand.StartRecording) + } + + @Test + fun `Given timed paired recording When recording starts Then both devices receive the same duration`() = runTest { + // Given + val fixture = Fixture() + + // When + fixture.user.start(request(watchSensorIds = setOf(1), durationSeconds = 12)) + + // Then + val watchStart = fixture.watch.commands.single() as WearCommand.StartRecording + assertEquals(12_000L, fixture.phone.startedDurationMillis) + assertEquals(fixture.phone.startedDurationMillis, watchStart.request.durationMillis) + } + + @Test + fun `Given watch start rejection When recording starts Then phone and watch are stopped`() = runTest { + // Given + val fixture = Fixture().apply { + watch.startResult = AppResult.failure(AppError(AppErrorCode.RECORDING, "Start watch recording")) + } + + // When + val result = fixture.user.start(request(watchSensorIds = setOf(1))) + + // Then + assertTrue(result.isFailure) + assertEquals(1, fixture.phone.stopCalls) + assertTrue(fixture.watch.commands.last() is WearCommand.StopRecording) + } + + @Test + fun `Given watch start delivery without a result When recording starts Then phone keeps recording`() = runTest { + // Given + val fixture = Fixture().apply { + watch.startResult = AppResult.failure( + AppError( + code = AppErrorCode.TIMEOUT, + operation = "Await watch start result", + diagnosticMessage = "Fixture result was lost", + context = mapOf("commandWasSent" to "true"), + ), + ) + } + + // When + val result = fixture.user.start(request(watchSensorIds = setOf(1), durationSeconds = 12)) + + // Then + assertTrue(result.isSuccess) + assertEquals(1, fixture.phone.startCalls) + assertEquals(0, fixture.phone.stopCalls) + } + + @Test + fun `Given paired recording When stopped Then phone and watch receive stop`() = runTest { + // Given + val fixture = Fixture() + fixture.user.start(request(watchSensorIds = setOf(1))) + + // When + val result = fixture.user.stop() + + // Then + assertTrue(result.isSuccess) + assertEquals(1, fixture.phone.stopCalls) + assertTrue(fixture.watch.commands.last() is WearCommand.StopRecording) + } + + @Test + fun `Given active recording When another start is requested Then intent is rejected`() = runTest { + // Given + val fixture = Fixture() + fixture.user.start(request()) + + // When + val result = fixture.user.start(request()) + + // Then + assertEquals(AppErrorCode.CONFLICT, result.errorOrNull()?.code) + assertEquals(1, fixture.phone.startCalls) + } + + @Test + fun `Given active recording When stale watch stop arrives Then phone keeps recording`() = runTest { + // Given + val fixture = Fixture() + fixture.user.start(request()) + + // When + val result = fixture.peer.stopFromWatch("stale-session", WearStopReason.USER_REQUEST) + + // Then + assertTrue(result.isSuccess) + assertEquals(0, fixture.phone.stopCalls) + } + + @Test + fun `Given automatic phone stop When watch is unreachable Then the next recording can start`() = runTest { + // Given + val fixture = Fixture() + fixture.user.start(request(watchSensorIds = setOf(1))) + val sessionId = (fixture.watch.commands.single() as WearCommand.StartRecording).sessionId + fixture.watch.stopResult = AppResult.failure(AppError(AppErrorCode.CONNECTIVITY, "Stop watch recording")) + + // When + val peerNotification = fixture.phoneEvents.onAutomaticPhoneStop( + sessionId, + WearStopReason.DURATION_EXPIRED, + ) + val nextStart = fixture.user.start(request()) + + // Then + assertTrue(peerNotification.isFailure) + assertTrue(nextStart.isSuccess) + assertEquals(2, fixture.phone.startCalls) + } + + @Test + fun `Given an annotation When recording controls it Then the current time reaches the phone`() { + // Given + val fixture = Fixture() + + // When + fixture.user.annotate("fixture note") + + // Then + assertEquals("fixture note", fixture.phone.annotation) + assertEquals(12_345L, fixture.phone.annotationTimeMillis) + } + + private fun request(watchSensorIds: Set = emptySet(), durationSeconds: Int = 0) = RecordingSetup( + sensorIds = setOf(1), + includesGps = false, + samplingPeriodIndex = 0, + stopOnLowBattery = false, + useWakeLock = false, + gpsIntervalSeconds = 10, + gpsMinDistanceMeters = 20, + watchSensorIds = watchSensorIds, + durationSeconds = durationSeconds, + ) + + private class Fixture { + val phone = FakePhoneRecordingController() + val watch = FakeRecordingCommandSender() + private val control = DefaultRecordingControlUseCase( + phoneRecording = phone, + watchCommands = watch, + diagnosticLogger = DiagnosticLogger { }, + clock = EpochClock { 12_345L }, + ) + val user: RecordingControlUseCase = control + val phoneEvents: PhoneRecordingSessionControl = control + val peer: PeerRecordingControl = control + } +} + +private class FakePhoneRecordingController : PhoneRecordingController { + var startCalls = 0 + var stopCalls = 0 + var startedDurationMillis = 0L + var annotation = "" + var annotationTimeMillis = 0L + + override fun start(sessionId: String, request: RecordingSetup): AppResult { + startCalls += 1 + startedDurationMillis = request.durationSeconds.coerceAtLeast(0) * 1_000L + return AppResult.success(StartedPhoneRecording(sessionId, "fixture", startedDurationMillis)) + } + + override fun stop(sessionId: String, reason: WearStopReason): AppResult { + stopCalls += 1 + return AppResult.success(Unit) + } + + override fun stopCurrent(reason: WearStopReason): AppResult { + stopCalls += 1 + return AppResult.success(Unit) + } + + override fun annotate(text: String, timestampMillis: Long): AppResult { + annotation = text + annotationTimeMillis = timestampMillis + return AppResult.success(Unit) + } +} + +private class FakeRecordingCommandSender : RecordingCommandSender { + val commands = mutableListOf() + var startResult: AppResult = AppResult.success(Unit) + var stopResult: AppResult = AppResult.success(Unit) + + override suspend fun send(command: WearCommand): AppResult { + commands += command + return when (command) { + is WearCommand.StartRecording -> startResult + is WearCommand.StopRecording -> stopResult + else -> error("Fixture only accepts recording commands") + } + } +} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/RecordingSourceAvailabilityTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/RecordingSourceAvailabilityTest.kt new file mode 100644 index 0000000..e88b18e --- /dev/null +++ b/app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/RecordingSourceAvailabilityTest.kt @@ -0,0 +1,112 @@ +package com.tomasrepcik.sensorbox.domain.sensors + +import com.tomasrepcik.sensorbox.wearoslib.connectivity.FakeWearConnectionRepository +import com.tomasrepcik.sensorbox.wearoslib.connectivity.ObserveWearCapabilityUseCase +import com.tomasrepcik.sensorbox.wearoslib.connectivity.SendWearMessageUseCase +import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearConnection +import com.tomasrepcik.sensorbox.wearoslib.connectivity.WearNode +import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommand +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearCommandCodec +import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RecordingSourceAvailabilityTest { + @Test + fun `Given connected watch When sources are observed Then discovery is requested`() = runTest { + // Given + val repository = connectedRepository() + val availability = availability(repository) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + availability.observe().collect() + } + + // When + advanceUntilIdle() + + // Then + val command = WearCommandCodec.decode(repository.sentMessages.single().payload).getOrThrow() + assertEquals(WearCommand.RequestAvailableSensors, command) + assertFalse(availability.current.isWatchConnected) + } + + @Test + fun `Given an empty watch response When it is received Then watch is available`() = runTest { + // Given + val availability = availability(connectedRepository()) + + // When + availability.receive(emptyList()) + + // Then + assertTrue(availability.current.isWatchConnected) + assertTrue(availability.current.watchSensors.isEmpty()) + } + + @Test + fun `Given watch sensors When received Then details are sorted and retained`() = runTest { + // Given + val availability = availability(connectedRepository()) + + // When + availability.receive(listOf(sensor(2, "Z sensor"), sensor(1, "A sensor"), sensor(1, "Duplicate"))) + + // Then + assertEquals(listOf(1, 2), availability.current.watchSensors.map(SensorDescriptor::type)) + assertEquals("A sensor", availability.current.watchSensors.first().name) + } + + @Test + fun `Given available watch sources When watch disconnects Then watch sources clear`() = runTest { + // Given + val repository = connectedRepository() + val availability = availability(repository) + availability.receive(listOf(sensor(1, "Sensor"))) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + availability.observe().collect() + } + + // When + repository.emit(WearConnection.Disconnected) + advanceUntilIdle() + + // Then + assertFalse(availability.current.isWatchConnected) + assertTrue(availability.current.watchSensors.isEmpty()) + } + + private fun availability(repository: FakeWearConnectionRepository) = RecordingSourceAvailability( + phoneSensors = AvailableSensorsUseCase { emptyList() }, + observeWatchCapability = ObserveWearCapabilityUseCase(repository), + sendWatchCommand = SendWearCommandUseCase(SendWearMessageUseCase(repository)), + ) + + private fun connectedRepository() = FakeWearConnectionRepository( + WearConnection.Connected(WearNode("watch", "Watch", isNearby = true)), + ) + + private fun sensor(type: Int, name: String) = WearSensorInfo( + type = type, + name = name, + vendor = "Fixture", + version = 1, + stringType = "sensor.$type", + maximumRange = 1f, + resolution = 1f, + power = 1f, + minimumDelayMicros = 1, + maximumDelayMicros = 1, + reportingMode = 0, + isWakeUpSensor = false, + ) +} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorCatalogStoreTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorCatalogStoreTest.kt deleted file mode 100644 index 0053880..0000000 --- a/app/src/test/java/com/tomasrepcik/sensorbox/domain/sensors/WatchSensorCatalogStoreTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.tomasrepcik.sensorbox.domain.sensors - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class WatchSensorCatalogStoreTest { - @Test - fun `Given an empty sensor response When catalog updates Then watch is available`() { - val catalog = WatchSensorCatalogStore() - - catalog.update(emptyList()) - - assertTrue(catalog.isAvailable.value) - } - - @Test - fun `Given an available watch app When catalog clears Then watch is unavailable`() { - val catalog = WatchSensorCatalogStore().apply { update(emptyList()) } - - catalog.clear() - - assertFalse(catalog.isAvailable.value) - } -} diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/FeatureViewModelTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/FeatureViewModelTest.kt index e7e7b7e..fe39c88 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/FeatureViewModelTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/FeatureViewModelTest.kt @@ -1,30 +1,36 @@ package com.tomasrepcik.sensorbox.presentation.main -import android.content.Intent import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppFailureStore import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger import com.tomasrepcik.sensorbox.core.error.DiagnosticsStore import com.tomasrepcik.sensorbox.core.preferences.AppThemeMode import com.tomasrepcik.sensorbox.core.testing.FakeAppPreferencesRepository +import com.tomasrepcik.sensorbox.domain.diagnostics.DiagnosticsShareFile +import com.tomasrepcik.sensorbox.domain.diagnostics.DiagnosticsShareFilePreparer +import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicense +import com.tomasrepcik.sensorbox.domain.licenses.OpenSourceLicenseRepository +import com.tomasrepcik.sensorbox.domain.preview.DevicePreviewRepository +import com.tomasrepcik.sensorbox.domain.preview.GpsPreviewData +import com.tomasrepcik.sensorbox.domain.preview.SensorPreviewData import com.tomasrepcik.sensorbox.domain.recording.RecordingArchiveRepository +import com.tomasrepcik.sensorbox.domain.recording.RecordingArchiveSelection import com.tomasrepcik.sensorbox.domain.recording.RecordingControlUseCase import com.tomasrepcik.sensorbox.domain.recording.RecordingPermissionsUseCase import com.tomasrepcik.sensorbox.domain.recording.RecordingSetup -import com.tomasrepcik.sensorbox.domain.sensors.AvailableSensorsUseCase +import com.tomasrepcik.sensorbox.domain.sensors.AvailableRecordingSources +import com.tomasrepcik.sensorbox.domain.sensors.AvailableRecordingSourcesUseCase import com.tomasrepcik.sensorbox.domain.sensors.SensorDescriptor -import com.tomasrepcik.sensorbox.domain.sensors.WatchSensorCatalogStore -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionState -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore import com.tomasrepcik.sensorbox.testing.MainDispatcherRule -import com.tomasrepcik.sensorbox.wearoslib.connectivity.FakeWearConnectionRepository -import com.tomasrepcik.sensorbox.wearoslib.connectivity.ObserveWearCapabilityUseCase -import com.tomasrepcik.sensorbox.wearoslib.connectivity.SendWearMessageUseCase -import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase -import com.tomasrepcik.sensorbox.wearoslib.protocol.WearSensorInfo import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle @@ -35,7 +41,6 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test -import java.io.File @OptIn(ExperimentalCoroutinesApi::class) class FeatureViewModelTest { @@ -48,6 +53,7 @@ class FeatureViewModelTest { val viewModel = OnboardingViewModel( preferencesRepository = FakeAppPreferencesRepository(), recordingArchive = FakeRecordingArchiveRepository(isSelected = true), + appFailures = failureStore(), ) val effect = async { viewModel.effects.first() } @@ -65,6 +71,7 @@ class FeatureViewModelTest { val viewModel = OnboardingViewModel( preferencesRepository = FakeAppPreferencesRepository(), recordingArchive = FakeRecordingArchiveRepository(isSelected = false), + appFailures = failureStore(), ) viewModel.accept(OnboardingIntent.CompleteOnboarding) @@ -72,12 +79,37 @@ class FeatureViewModelTest { assertEquals(AppErrorCode.STORAGE, viewModel.state.value.errorCode) } + @Test + fun `Given archive lookup fails When onboarding completes Then app failure is visible`() = runTest { + // Given + val appFailures = failureStore() + val archive = FakeRecordingArchiveRepository( + selectedResult = AppResult.failure(AppError(AppErrorCode.STORAGE, "Check fixture archive")), + ) + val viewModel = OnboardingViewModel( + FakeAppPreferencesRepository(), + archive, + appFailures, + ) + + // When + viewModel.accept(OnboardingIntent.CompleteOnboarding) + + // Then + assertEquals(AppErrorCode.STORAGE, appFailures.visibleFailure.value?.code) + assertEquals(null, viewModel.state.value.errorCode) + } + @Test fun `Given settings intent When sampling changes Then settings owns updated preference state`() = runTest { val viewModel = SettingsViewModel( preferencesRepository = FakeAppPreferencesRepository(), diagnosticsStore = FakeDiagnosticsStore(), ioDispatcher = mainDispatcherRule.dispatcher, + appFailures = failureStore(), + devicePreview = FakeDevicePreviewRepository(), + licenses = FakeOpenSourceLicenseRepository(), + diagnosticsShareFile = FakeDiagnosticsShareFilePreparer(), ) advanceUntilIdle() @@ -93,6 +125,10 @@ class FeatureViewModelTest { preferencesRepository = FakeAppPreferencesRepository(), diagnosticsStore = FakeDiagnosticsStore(), ioDispatcher = mainDispatcherRule.dispatcher, + appFailures = failureStore(), + devicePreview = FakeDevicePreviewRepository(), + licenses = FakeOpenSourceLicenseRepository(), + diagnosticsShareFile = FakeDiagnosticsShareFilePreparer(), ) advanceUntilIdle() @@ -103,22 +139,25 @@ class FeatureViewModelTest { } @Test - fun `Given diagnostics read failure When viewed Then settings emits the stable error code`() = runTest { + fun `Given diagnostics read failure When viewed Then app failure is visible`() = runTest { val diagnostics = FakeDiagnosticsStore( readResult = AppResult.failure(AppError(AppErrorCode.STORAGE, "Read fixture diagnostics")), ) + val appFailures = failureStore() val viewModel = SettingsViewModel( FakeAppPreferencesRepository(), diagnostics, mainDispatcherRule.dispatcher, + appFailures, + FakeDevicePreviewRepository(), + FakeOpenSourceLicenseRepository(), + FakeDiagnosticsShareFilePreparer(), ) - val effect = async { viewModel.effects.first() } viewModel.accept(SettingsIntent.ViewDiagnostics) advanceUntilIdle() - assertEquals(AppErrorCode.STORAGE, viewModel.state.value.errorCode) - assertEquals(SettingsEffect.DiagnosticsFailed(AppErrorCode.STORAGE), effect.await()) + assertEquals(AppErrorCode.STORAGE, appFailures.visibleFailure.value?.code) } @Test @@ -132,6 +171,29 @@ class FeatureViewModelTest { assertEquals(AppErrorCode.VALIDATION, viewModel.state.value.errorCode) } + @Test + fun `Given archive lookup fails When recording starts Then app failure is visible`() = runTest { + // Given + val appFailures = failureStore() + val archive = FakeRecordingArchiveRepository( + selectedResult = AppResult.failure(AppError(AppErrorCode.STORAGE, "Check fixture archive")), + ) + val viewModel = recordingViewModel( + workflow = FakeRecordingWorkflow(), + archive = archive, + appFailures = appFailures, + ) + advanceUntilIdle() + viewModel.accept(RecordingIntent.ToggleSensor(1)) + + // When + viewModel.accept(RecordingIntent.StartRecording) + + // Then + assertEquals(AppErrorCode.STORAGE, appFailures.visibleFailure.value?.code) + assertEquals(RecordingMessage.NONE, viewModel.state.value.message) + } + @Test fun `Given watch permission rejection When recording starts Then watch guidance is shown`() = runTest { val workflow = FakeRecordingWorkflow().apply { @@ -233,27 +295,31 @@ class FeatureViewModelTest { } @Test - fun `Given a Wear catalog When observed Then every sensor detail is retained`() = runTest { - val catalog = WatchSensorCatalogStore() - val viewModel = recordingViewModel(FakeRecordingWorkflow(), catalog) + fun `Given available watch sources When observed Then every sensor detail is retained`() = runTest { + val sources = FakeAvailableRecordingSources() + val viewModel = recordingViewModel(FakeRecordingWorkflow(), sources) advanceUntilIdle() - catalog.update( - listOf( - WearSensorInfo( - type = 1, - name = "Wear Accelerometer", - vendor = "Fixture", - version = 7, - stringType = "android.sensor.accelerometer", - maximumRange = 78.4f, - resolution = 0.0024f, - power = 0.25f, - minimumDelayMicros = 5_000, - maximumDelayMicros = 200_000, - reportingMode = 0, - isWakeUpSensor = true, + sources.update( + AvailableRecordingSources( + phoneSensors = emptyList(), + watchSensors = listOf( + SensorDescriptor( + type = 1, + name = "Wear Accelerometer", + vendor = "Fixture", + version = 7, + stringType = "android.sensor.accelerometer", + maximumRange = 78.4f, + resolution = 0.0024f, + power = 0.25f, + minimumDelayMicros = 5_000, + maximumDelayMicros = 200_000, + reportingMode = com.tomasrepcik.sensorbox.domain.sensors.SensorReportingMode.CONTINUOUS, + isWakeUpSensor = true, + ), ), + isWatchConnected = true, ), ) advanceUntilIdle() @@ -279,31 +345,52 @@ class FeatureViewModelTest { private fun recordingViewModel( workflow: RecordingControlUseCase, - watchSensorCatalog: WatchSensorCatalogStore = WatchSensorCatalogStore(), + availableSources: AvailableRecordingSourcesUseCase = FakeAvailableRecordingSources(), sessionStore: RecordingSessionStore = RecordingSessionStore(), - ): RecordingViewModel { - val repository = FakeWearConnectionRepository() - return RecordingViewModel( - preferencesRepository = FakeAppPreferencesRepository(), - availableSensors = AvailableSensorsUseCase { emptyList() }, - recordingArchive = FakeRecordingArchiveRepository(isSelected = true), - permissions = RecordingPermissionsUseCase { emptySet() }, - recording = workflow, - sessionStore = sessionStore, - observeWatchCapability = ObserveWearCapabilityUseCase(repository), - sendWatchCommand = SendWearCommandUseCase(SendWearMessageUseCase(repository)), - watchSensorCatalog = watchSensorCatalog, - elapsedRealtimeClock = ElapsedRealtimeClock { 10_000L }, - ) + archive: RecordingArchiveRepository = FakeRecordingArchiveRepository(isSelected = true), + appFailures: AppFailureStore = failureStore(), + ): RecordingViewModel = RecordingViewModel( + preferencesRepository = FakeAppPreferencesRepository(), + availableSources = availableSources, + recordingArchive = archive, + permissions = RecordingPermissionsUseCase { emptySet() }, + recording = workflow, + sessionStore = sessionStore, + elapsedRealtimeClock = ElapsedRealtimeClock { 10_000L }, + appFailures = appFailures, + devicePreview = FakeDevicePreviewRepository(), + ) + + private fun failureStore() = AppFailureStore(DiagnosticLogger { }) +} + +private class FakeAvailableRecordingSources( + initial: AvailableRecordingSources = AvailableRecordingSources(phoneSensors = emptyList()), +) : AvailableRecordingSourcesUseCase { + private val sources = MutableStateFlow(initial) + + override val current: AvailableRecordingSources + get() = sources.value + + override fun observe(): Flow = sources + + fun update(value: AvailableRecordingSources) { + sources.value = value } } -private class FakeRecordingArchiveRepository(private val isSelected: Boolean) : RecordingArchiveRepository { - override fun isSelected(): AppResult = AppResult.success(isSelected) +private class FakeRecordingArchiveRepository( + isSelected: Boolean = true, + private val selectedResult: AppResult = AppResult.success(isSelected), +) : RecordingArchiveRepository { + override fun isSelected(): AppResult = selectedResult - override fun path(): AppResult = AppResult.success(if (isSelected) "fixture" else null) + override fun path(): AppResult = selectedResult.fold( + onSuccess = { AppResult.success(if (it) "fixture" else null) }, + onFailure = { AppResult.success(null) }, + ) - override fun select(resultIntent: Intent): AppResult = AppResult.success(Unit) + override fun select(selection: RecordingArchiveSelection.Selected): AppResult = AppResult.success(Unit) } private class FakeDiagnosticsStore( @@ -311,13 +398,29 @@ private class FakeDiagnosticsStore( ) : DiagnosticsStore { override fun readText(): AppResult = readResult - override fun exportFile(): AppResult = AppResult.failure( - AppError(AppErrorCode.STORAGE, "Export fixture diagnostics"), - ) - override fun clear(): AppResult = AppResult.success(Unit) } +private class FakeDevicePreviewRepository : DevicePreviewRepository { + override fun observeGps(intervalSeconds: Int, minimumDistanceMeters: Int) = + kotlinx.coroutines.flow.flowOf(AppResult.success(GpsPreviewData(hasPermission = true))) + + override fun observeSensor(sensorType: Int) = + kotlinx.coroutines.flow.flowOf(AppResult.success(SensorPreviewData(isAvailable = true))) + + override fun batteryOptimizationExemption(): AppResult = AppResult.success(false) +} + +private class FakeOpenSourceLicenseRepository : OpenSourceLicenseRepository { + override fun load(): AppResult> = AppResult.success(emptyList()) +} + +private class FakeDiagnosticsShareFilePreparer : DiagnosticsShareFilePreparer { + override fun prepare(text: String): AppResult = AppResult.success( + DiagnosticsShareFile("content://diagnostics", "diagnostics.jsonl", "application/x-ndjson"), + ) +} + private class FakeRecordingWorkflow : RecordingControlUseCase { var startResult: AppResult = AppResult.success(Unit) var startGate: CompletableDeferred? = null diff --git a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModelTest.kt b/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModelTest.kt index 6c4a68e..086e7d5 100644 --- a/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModelTest.kt +++ b/app/src/test/java/com/tomasrepcik/sensorbox/presentation/main/MeasurementBrowserViewModelTest.kt @@ -2,7 +2,9 @@ package com.tomasrepcik.sensorbox.presentation.main import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppFailureStore import com.tomasrepcik.sensorbox.core.error.AppResult +import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger import com.tomasrepcik.sensorbox.domain.measurements.GpsCoordinate import com.tomasrepcik.sensorbox.domain.measurements.MeasurementDetails import com.tomasrepcik.sensorbox.domain.measurements.MeasurementFileContent @@ -30,7 +32,7 @@ class MeasurementBrowserViewModelTest { @Test fun `Given saved measurements When refreshed Then newest folders are exposed`() = runTest { val repository = FakeMeasurementRepository() - val viewModel = MeasurementBrowserViewModel(repository) + val viewModel = MeasurementBrowserViewModel(repository, failureStore()) viewModel.onIntent(MeasurementBrowserIntent.RefreshMeasurements) advanceUntilIdle() @@ -42,7 +44,7 @@ class MeasurementBrowserViewModelTest { @Test fun `Given a measurement When its GPS file opens Then details content and navigation are retained`() = runTest { val repository = FakeMeasurementRepository() - val viewModel = MeasurementBrowserViewModel(repository) + val viewModel = MeasurementBrowserViewModel(repository, failureStore()) val detailsEffect = async { viewModel.effects.first() } viewModel.onIntent(MeasurementBrowserIntent.OpenMeasurementDetails(repository.summary.id)) @@ -73,7 +75,7 @@ class MeasurementBrowserViewModelTest { AppError(AppErrorCode.STORAGE, "Read fixture measurement repository"), ) } - val viewModel = MeasurementBrowserViewModel(repository) + val viewModel = MeasurementBrowserViewModel(repository, failureStore()) viewModel.onIntent(MeasurementBrowserIntent.RefreshMeasurements) advanceUntilIdle() @@ -81,6 +83,8 @@ class MeasurementBrowserViewModelTest { assertFalse(viewModel.state.value.isLoading) assertEquals(AppErrorCode.STORAGE, viewModel.state.value.errorCode) } + + private fun failureStore() = AppFailureStore(DiagnosticLogger { }) } private class FakeMeasurementRepository : MeasurementRepository { diff --git a/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/AppFailureStore.kt b/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/AppFailureStore.kt new file mode 100644 index 0000000..2bc3a10 --- /dev/null +++ b/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/AppFailureStore.kt @@ -0,0 +1,24 @@ +package com.tomasrepcik.sensorbox.core.error + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class AppFailureStore(private val diagnosticLogger: DiagnosticLogger) { + private val mutableVisibleFailure = MutableStateFlow(null) + + val visibleFailure: StateFlow = mutableVisibleFailure.asStateFlow() + + fun show(error: AppError) { + recordOnly(error) + mutableVisibleFailure.compareAndSet(expect = null, update = error) + } + + fun recordOnly(error: AppError) { + diagnosticLogger.record(error.toDiagnosticEvent()) + } + + fun dismiss() { + mutableVisibleFailure.value = null + } +} diff --git a/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/Diagnostics.kt b/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/Diagnostics.kt index fac60c4..26e9319 100644 --- a/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/Diagnostics.kt +++ b/core-common/src/main/kotlin/com/tomasrepcik/sensorbox/core/error/Diagnostics.kt @@ -1,7 +1,5 @@ package com.tomasrepcik.sensorbox.core.error -import java.io.File - enum class DiagnosticSeverity { INFO, WARNING, @@ -35,7 +33,5 @@ fun interface DiagnosticLogger { interface DiagnosticsStore { fun readText(): AppResult - fun exportFile(): AppResult - fun clear(): AppResult } diff --git a/core-common/src/test/kotlin/com/tomasrepcik/sensorbox/core/error/AppFailureStoreTest.kt b/core-common/src/test/kotlin/com/tomasrepcik/sensorbox/core/error/AppFailureStoreTest.kt new file mode 100644 index 0000000..daf7092 --- /dev/null +++ b/core-common/src/test/kotlin/com/tomasrepcik/sensorbox/core/error/AppFailureStoreTest.kt @@ -0,0 +1,70 @@ +package com.tomasrepcik.sensorbox.core.error + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AppFailureStoreTest { + @Test + fun `Given no visible failure When one is shown Then it becomes visible and is recorded`() { + // Given + val fixture = fixture() + val failure = AppError(AppErrorCode.STORAGE, "Load archive") + + // When + fixture.store.show(failure) + + // Then + assertEquals(failure, fixture.store.visibleFailure.value) + assertEquals(1, fixture.events.size) + } + + @Test + fun `Given a visible failure When another is shown Then first remains visible and both are recorded`() { + // Given + val fixture = fixture() + val first = AppError(AppErrorCode.STORAGE, "Load archive") + val second = AppError(AppErrorCode.CONNECTIVITY, "Connect watch") + fixture.store.show(first) + + // When + fixture.store.show(second) + + // Then + assertEquals(first, fixture.store.visibleFailure.value) + assertEquals(2, fixture.events.size) + } + + @Test + fun `Given a visible failure When dismissed Then no failure remains visible`() { + // Given + val fixture = fixture() + fixture.store.show(AppError(AppErrorCode.UNKNOWN, "Run operation")) + + // When + fixture.store.dismiss() + + // Then + assertNull(fixture.store.visibleFailure.value) + } + + @Test + fun `Given a recording source failure When recorded only Then diagnostics receive it without visible failure`() { + // Given + val fixture = fixture() + + // When + fixture.store.recordOnly(AppError(AppErrorCode.RECORDING, "Write accelerometer")) + + // Then + assertNull(fixture.store.visibleFailure.value) + assertEquals(1, fixture.events.size) + } + + private fun fixture(): Fixture { + val events = mutableListOf() + return Fixture(AppFailureStore(events::add), events) + } + + private data class Fixture(val store: AppFailureStore, val events: MutableList) +} diff --git a/core/src/main/java/com/tomasrepcik/sensorbox/core/error/FileDiagnostics.kt b/core/src/main/java/com/tomasrepcik/sensorbox/core/error/FileDiagnostics.kt index 2202eb2..8657832 100644 --- a/core/src/main/java/com/tomasrepcik/sensorbox/core/error/FileDiagnostics.kt +++ b/core/src/main/java/com/tomasrepcik/sensorbox/core/error/FileDiagnostics.kt @@ -74,14 +74,6 @@ class FileDiagnostics internal constructor( } } - override fun exportFile(): AppResult = appResult(AppErrorCode.STORAGE, "Export diagnostics") { - synchronized(fileLock) { - val text = readTextLocked() - diagnosticsDirectory.mkdirs() - exportFilePath().apply { writeText(text) } - } - } - override fun clear(): AppResult = appResult(AppErrorCode.STORAGE, "Clear diagnostics") { synchronized(fileLock) { diagnosticFiles().plus(exportFilePath()).forEach { file -> diff --git a/core/src/main/java/com/tomasrepcik/sensorbox/core/storage/NativeDocumentStorage.kt b/core/src/main/java/com/tomasrepcik/sensorbox/core/storage/NativeDocumentStorage.kt index d7f53d7..3f32512 100644 --- a/core/src/main/java/com/tomasrepcik/sensorbox/core/storage/NativeDocumentStorage.kt +++ b/core/src/main/java/com/tomasrepcik/sensorbox/core/storage/NativeDocumentStorage.kt @@ -2,6 +2,7 @@ package com.tomasrepcik.sensorbox.core.storage import android.content.Context import android.content.Intent +import android.net.Uri import androidx.documentfile.provider.DocumentFile import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode @@ -12,7 +13,7 @@ import java.io.InputStream import java.io.OutputStream interface DocumentStorage { - fun persistRootAccess(intent: Intent): AppResult + fun persistRootAccess(uri: String, grantFlags: Int): AppResult fun hasConfiguredDirectory(): AppResult @@ -38,30 +39,30 @@ interface DocumentStorage { } class NativeDocumentStorage(private val context: Context) : DocumentStorage { - override fun persistRootAccess(intent: Intent): AppResult { - val uri = intent.data ?: return storageFailure("Storage directory was not selected") - val grantFlags = intent.flags and READ_WRITE_FLAGS - if (grantFlags == 0) return storageFailure("Storage permission was not granted") + override fun persistRootAccess(uri: String, grantFlags: Int): AppResult { + val selectedUri = Uri.parse(uri) + val persistedFlags = grantFlags and READ_WRITE_FLAGS + if (persistedFlags == 0) return storageFailure("Storage permission was not granted") return appResult(AppErrorCode.STORAGE, "Persist storage permission") { - when (grantFlags) { + when (persistedFlags) { Intent.FLAG_GRANT_READ_URI_PERMISSION -> context.contentResolver.takePersistableUriPermission( - uri, + selectedUri, Intent.FLAG_GRANT_READ_URI_PERMISSION, ) Intent.FLAG_GRANT_WRITE_URI_PERMISSION -> context.contentResolver.takePersistableUriPermission( - uri, + selectedUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION, ) - else -> context.contentResolver.takePersistableUriPermission(uri, READ_WRITE_FLAGS) + else -> context.contentResolver.takePersistableUriPermission(selectedUri, READ_WRITE_FLAGS) } - DocumentFile.fromTreeUri(context, uri) + DocumentFile.fromTreeUri(context, selectedUri) }.flatMap { selectedDirectory -> if (selectedDirectory?.isDirectory != true) { return@flatMap storageFailure("Selected storage location is not a directory") } - releaseOtherRootPermissions(context, uri) + releaseOtherRootPermissions(context, selectedUri) if (configuredDirectory() == null) { storageFailure("Storage directory is unavailable") } else { diff --git a/core/src/test/java/com/tomasrepcik/sensorbox/core/error/FileDiagnosticsTest.kt b/core/src/test/java/com/tomasrepcik/sensorbox/core/error/FileDiagnosticsTest.kt index c917cc0..fde0b94 100644 --- a/core/src/test/java/com/tomasrepcik/sensorbox/core/error/FileDiagnosticsTest.kt +++ b/core/src/test/java/com/tomasrepcik/sensorbox/core/error/FileDiagnosticsTest.kt @@ -82,18 +82,6 @@ class FileDiagnosticsTest { assertEquals("", diagnostics.readText().getOrNull()) } - @Test - fun `Given retained logs When exported Then ordered text is copied to the export file`() { - val diagnostics = diagnostics(temporaryFolder.newFolder("diagnostics")) - diagnostics.record(event(message = "First")) - diagnostics.record(event(message = "Second")) - - val exported = diagnostics.exportFile().getOrNull() - - assertEquals(diagnostics.readText().getOrNull(), exported?.readText()) - assertTrue(exported?.name?.contains("export") == true) - } - @Test fun `Given concurrent installation When an exception is dispatched Then the handler is installed once`() { val diagnostics = diagnostics(temporaryFolder.newFolder("diagnostics")) diff --git a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionState.kt b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionState.kt new file mode 100644 index 0000000..17b9819 --- /dev/null +++ b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionState.kt @@ -0,0 +1,15 @@ +package com.tomasrepcik.sensorbox.recording.session + +sealed interface RecordingSessionState { + data object Idle : RecordingSessionState + + data class Running( + val sessionId: String, + val folderName: String, + val startedAtElapsedRealtime: Long, + val sensorIds: List, + val includesGps: Boolean, + ) : RecordingSessionState + + data object Stopping : RecordingSessionState +} diff --git a/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionStore.kt b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionStore.kt new file mode 100644 index 0000000..59efbf8 --- /dev/null +++ b/recording-core/src/main/kotlin/com/tomasrepcik/sensorbox/recording/session/RecordingSessionStore.kt @@ -0,0 +1,48 @@ +package com.tomasrepcik.sensorbox.recording.session + +import com.tomasrepcik.sensorbox.core.error.AppResult +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +class RecordingSessionStore { + private val mutableState = MutableStateFlow(RecordingSessionState.Idle) + private val mutableEvents = MutableSharedFlow(extraBufferCapacity = EVENT_BUFFER_SIZE) + val state: StateFlow = mutableState.asStateFlow() + val events: SharedFlow = mutableEvents + + fun markRunning(state: RecordingSessionState.Running) { + mutableState.value = state + } + + fun markStopping() { + mutableState.value = RecordingSessionState.Stopping + } + + fun markIdle() { + mutableState.value = RecordingSessionState.Idle + } + + fun publishStopped(sessionId: String, reason: RecordingSessionStopReason, result: AppResult) { + mutableEvents.tryEmit(RecordingSessionStopped(sessionId, reason, result)) + } + + private companion object { + const val EVENT_BUFFER_SIZE = 16 + } +} + +enum class RecordingSessionStopReason { + USER_REQUEST, + DURATION_EXPIRED, + LOW_BATTERY, + SOURCE_FAILURE, + SERVICE_DESTROYED, +} + +data class RecordingSessionStopped( + val sessionId: String, + val reason: RecordingSessionStopReason, + val result: AppResult, +) diff --git a/sensorservices/build.gradle.kts b/sensorservices/build.gradle.kts index 9b3c23f..1eff022 100644 --- a/sensorservices/build.gradle.kts +++ b/sensorservices/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { ksp(libs.hilt.compiler) testImplementation(libs.junit) + testImplementation(libs.coroutines.test) } hilt { diff --git a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/di/StorageModule.kt b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/di/StorageModule.kt index caf578e..9b3b57e 100644 --- a/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/di/StorageModule.kt +++ b/sensorservices/src/main/java/com/tomasrepcik/sensorbox/sensorservices/di/StorageModule.kt @@ -5,6 +5,7 @@ import com.tomasrepcik.sensorbox.core.storage.DocumentStorage import com.tomasrepcik.sensorbox.core.storage.NativeDocumentStorage import com.tomasrepcik.sensorbox.core.time.EpochClock import com.tomasrepcik.sensorbox.core.time.SystemEpochClock +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -22,4 +23,8 @@ object StorageModule { @Provides @Singleton fun provideEpochClock(): EpochClock = SystemEpochClock + + @Provides + @Singleton + fun provideRecordingSessionStore(): RecordingSessionStore = RecordingSessionStore() } diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 1e1f7d9..4a06d78 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { implementation(project(":core")) implementation(project(":wearoslib")) implementation(project(":sensorservices")) + implementation(project(":recording-core")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.datastore.preferences) diff --git a/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreenTest.kt b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreenTest.kt new file mode 100644 index 0000000..4524715 --- /dev/null +++ b/wear/src/androidTest/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreenTest.kt @@ -0,0 +1,32 @@ +package com.tomasrepcik.sensorbox.presentation.dashboard + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class WearAppErrorScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun givenStorageFailureWhenRenderedThenLocalizedMessageCanBeDismissed() { + // Given + var dismissed = false + + // When + composeRule.setContent { + WearAppErrorScreen(AppErrorCode.STORAGE) { dismissed = true } + } + + // Then + composeRule.onNodeWithText("Something went wrong").assertIsDisplayed() + composeRule.onNodeWithText("Local storage could not be used. The error was saved.").assertIsDisplayed() + composeRule.onNodeWithText("Close").performClick() + assertTrue(dismissed) + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt index 02264bd..5eff9f5 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/activities/MainActivity.kt @@ -59,7 +59,6 @@ class MainActivity : ComponentActivity() { ) { WearDashboardScreen( state = state, - chartModelProducer = viewModel.chartModelProducer, accept = viewModel::accept, ) } @@ -73,12 +72,12 @@ class MainActivity : ComponentActivity() { "Request Wear permissions", ) { permissions.launch(effect.permissions.toTypedArray()) - } + }.onFailure(viewModel::reportFailure) WearDashboardEffect.OpenPhone -> appResult( AppErrorCode.EXTERNAL_ACTION, "Open phone launcher", - ) { startActivity(Intent(this, MoveToMain::class.java)) } + ) { startActivity(Intent(this, MoveToMain::class.java)) }.onFailure(viewModel::reportFailure) is WearDashboardEffect.OpenUrl -> openOnPhone(effect.destination) } @@ -102,16 +101,12 @@ class MainActivity : ComponentActivity() { onSuccess = { Toast.makeText(this, R.string.open_phone_browser, Toast.LENGTH_SHORT).show() }, - onFailure = { - Toast.makeText(this, R.string.open_phone_browser_failed, Toast.LENGTH_LONG).show() - }, + onFailure = viewModel::reportFailure, ) }, ContextCompat.getMainExecutor(this), ) - }.onFailure { - Toast.makeText(this, R.string.open_phone_browser_failed, Toast.LENGTH_LONG).show() - } + }.onFailure(viewModel::reportFailure) } private fun updateDisplayPolicy(keepDisplayOn: Boolean) { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/activities/MoveToMain.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/activities/MoveToMain.kt index 9839f0d..2f9b424 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/activities/MoveToMain.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/activities/MoveToMain.kt @@ -6,7 +6,6 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.tomasrepcik.sensorbox.presentation.phonelaunch.PhoneLaunchIntent import com.tomasrepcik.sensorbox.presentation.phonelaunch.PhoneLaunchScreen import com.tomasrepcik.sensorbox.presentation.phonelaunch.PhoneLaunchViewModel import com.tomasrepcik.sensorbox.ui.theme.WearSensorBoxTheme @@ -23,7 +22,7 @@ class MoveToMain : ComponentActivity() { WearSensorBoxTheme { PhoneLaunchScreen( state = state, - onLaunchPhone = { viewModel.accept(PhoneLaunchIntent.LaunchRequested) }, + onIntent = viewModel::accept, ) } } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt index fabf44f..517a3a1 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/di/DiagnosticsModule.kt @@ -4,6 +4,7 @@ import android.app.Application import android.content.Context import android.content.pm.ApplicationInfo import android.os.Build +import com.tomasrepcik.sensorbox.core.error.AppFailureStore import com.tomasrepcik.sensorbox.core.error.CompositeDiagnosticLogger import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger import com.tomasrepcik.sensorbox.core.error.DiagnosticMetadata @@ -45,6 +46,10 @@ object DiagnosticsModule { fun provideDiagnosticLogger(diagnostics: FileDiagnostics): DiagnosticLogger = CompositeDiagnosticLogger(diagnostics, LogcatDiagnosticLogger()) + @Provides + @Singleton + fun provideAppFailureStore(diagnosticLogger: DiagnosticLogger): AppFailureStore = AppFailureStore(diagnosticLogger) + @Provides fun provideDiagnosticsStore(diagnostics: FileDiagnostics): DiagnosticsStore = diagnostics } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt new file mode 100644 index 0000000..806bb43 --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/di/RecordingCommandExchangeModule.kt @@ -0,0 +1,32 @@ +package com.tomasrepcik.sensorbox.di + +import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_APP_CAPABILITY +import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH +import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingCommandExchange +import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingCommandSender +import com.tomasrepcik.sensorbox.wearoslib.protocol.RecordingResultReceiver +import com.tomasrepcik.sensorbox.wearoslib.protocol.SendWearCommandUseCase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object RecordingCommandExchangeModule { + @Provides + @Singleton + fun provideRecordingCommandExchange(sendCommand: SendWearCommandUseCase) = RecordingCommandExchange( + sendCommand = sendCommand, + peerCapability = PHONE_APP_CAPABILITY, + peerPath = PHONE_MESSAGE_PATH, + peerName = "phone", + ) + + @Provides + fun provideRecordingCommandSender(exchange: RecordingCommandExchange): RecordingCommandSender = exchange + + @Provides + fun provideRecordingResultReceiver(exchange: RecordingCommandExchange): RecordingResultReceiver = exchange +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/di/SyncModule.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/di/SyncModule.kt new file mode 100644 index 0000000..9ece377 --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/di/SyncModule.kt @@ -0,0 +1,17 @@ +package com.tomasrepcik.sensorbox.di + +import com.tomasrepcik.sensorbox.domain.sync.DefaultSyncWatchMeasurementsUseCase +import com.tomasrepcik.sensorbox.domain.sync.SyncWatchMeasurementsUseCase +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class SyncModule { + @Binds + abstract fun bindSyncWatchMeasurementsUseCase( + implementation: DefaultSyncWatchMeasurementsUseCase, + ): SyncWatchMeasurementsUseCase +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sync/SyncWatchMeasurementsUseCase.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sync/SyncWatchMeasurementsUseCase.kt index 7497642..b53102a 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sync/SyncWatchMeasurementsUseCase.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/domain/sync/SyncWatchMeasurementsUseCase.kt @@ -16,12 +16,16 @@ import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import javax.inject.Inject -class SyncWatchMeasurementsUseCase @Inject constructor( +fun interface SyncWatchMeasurementsUseCase { + suspend operator fun invoke(): AppResult +} + +class DefaultSyncWatchMeasurementsUseCase @Inject constructor( @ApplicationContext private val context: Context, private val connectionRepository: WearConnectionRepository, private val transferClient: WearFileTransferClient, -) { - suspend operator fun invoke(): AppResult = suspendAppResult(AppErrorCode.CONNECTIVITY, "Find phone") { +) : SyncWatchMeasurementsUseCase { + override suspend fun invoke(): AppResult = suspendAppResult(AppErrorCode.CONNECTIVITY, "Find phone") { connectionRepository.findNode(PHONE_APP_CAPABILITY) }.suspendFlatMap { node -> if (node == null) { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearButtons.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearButtons.kt new file mode 100644 index 0000000..5168f6e --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearButtons.kt @@ -0,0 +1,59 @@ +package com.tomasrepcik.sensorbox.presentation + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnItemScope +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.EdgeButton +import androidx.wear.compose.material3.EdgeButtonSize +import androidx.wear.compose.material3.OutlinedButton +import androidx.wear.compose.material3.SurfaceTransformation +import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.lazy.TransformationSpec +import androidx.wear.compose.material3.lazy.transformedHeight + +@Composable +internal fun TransformingLazyColumnItemScope.WearPrimaryButton( + label: String, + transformation: TransformationSpec, + enabled: Boolean = true, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier.fillMaxWidth().transformedHeight(this, transformation), + transformation = SurfaceTransformation(transformation), + ) { + Text(label) + } +} + +@Composable +internal fun TransformingLazyColumnItemScope.WearSecondaryButton( + label: String, + transformation: TransformationSpec, + onClick: () -> Unit, +) { + OutlinedButton( + onClick = onClick, + modifier = Modifier + .padding(horizontal = 36.dp) + .fillMaxWidth() + .transformedHeight(this, transformation), + transformation = SurfaceTransformation(transformation), + ) { + Text(text = label, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + } +} + +@Composable +internal fun WearPrimaryEdgeButton(label: String, enabled: Boolean = true, onClick: () -> Unit) { + EdgeButton(onClick = onClick, enabled = enabled, buttonSize = EdgeButtonSize.Small) { + Text(label) + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearScreenComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearScreenComponents.kt new file mode 100644 index 0000000..ef18140 --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearScreenComponents.kt @@ -0,0 +1,65 @@ +package com.tomasrepcik.sensorbox.presentation + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.wear.compose.foundation.lazy.TransformingLazyColumn +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnItemScope +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnScope +import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState +import androidx.wear.compose.material3.AppScaffold +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.SurfaceTransformation +import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.lazy.TransformationSpec +import androidx.wear.compose.material3.lazy.rememberTransformationSpec +import androidx.wear.compose.material3.lazy.transformedHeight + +@Composable +internal fun WearListScreen( + edgeButton: (@Composable () -> Unit)? = null, + content: TransformingLazyColumnScope.(TransformationSpec) -> Unit, +) { + val listState = rememberTransformingLazyColumnState() + val transformation = rememberTransformationSpec() + AppScaffold { + if (edgeButton == null) { + ScreenScaffold(scrollState = listState) { padding -> + TransformingLazyColumn(state = listState, contentPadding = padding) { + content(transformation) + } + } + } else { + ScreenScaffold( + scrollState = listState, + edgeButton = { edgeButton() }, + ) { padding -> + TransformingLazyColumn(state = listState, contentPadding = padding) { + content(transformation) + } + } + } + } +} + +@Composable +internal fun TransformingLazyColumnItemScope.WearPageTitle(label: String, transformation: TransformationSpec) { + ListHeader( + modifier = Modifier.fillMaxWidth().transformedHeight(this, transformation), + transformation = SurfaceTransformation(transformation), + ) { + Text(label, color = MaterialTheme.colorScheme.onBackground) + } +} + +@Composable +internal fun TransformingLazyColumnItemScope.WearSectionTitle(label: String, transformation: TransformationSpec) { + ListHeader( + modifier = Modifier.fillMaxWidth().transformedHeight(this, transformation), + transformation = SurfaceTransformation(transformation), + ) { + Text(label, color = MaterialTheme.colorScheme.primary) + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearSelectionComponents.kt similarity index 50% rename from wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearComponents.kt rename to wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearSelectionComponents.kt index ba1aad8..a755238 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearComponents.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/WearSelectionComponents.kt @@ -2,88 +2,27 @@ package com.tomasrepcik.sensorbox.presentation import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.wear.compose.foundation.lazy.TransformingLazyColumn import androidx.wear.compose.foundation.lazy.TransformingLazyColumnItemScope -import androidx.wear.compose.foundation.lazy.TransformingLazyColumnScope -import androidx.wear.compose.foundation.lazy.rememberTransformingLazyColumnState -import androidx.wear.compose.material3.AppScaffold -import androidx.wear.compose.material3.Button import androidx.wear.compose.material3.CheckboxButton import androidx.wear.compose.material3.CheckboxButtonDefaults import androidx.wear.compose.material3.ChildButton -import androidx.wear.compose.material3.EdgeButton -import androidx.wear.compose.material3.EdgeButtonSize import androidx.wear.compose.material3.Icon -import androidx.wear.compose.material3.ListHeader import androidx.wear.compose.material3.MaterialTheme -import androidx.wear.compose.material3.OutlinedButton import androidx.wear.compose.material3.RadioButton import androidx.wear.compose.material3.RadioButtonDefaults -import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.SurfaceTransformation import androidx.wear.compose.material3.SwitchButton import androidx.wear.compose.material3.SwitchButtonDefaults import androidx.wear.compose.material3.Text import androidx.wear.compose.material3.lazy.TransformationSpec -import androidx.wear.compose.material3.lazy.rememberTransformationSpec import androidx.wear.compose.material3.lazy.transformedHeight -@Composable -internal fun WearListScreen( - edgeButton: (@Composable () -> Unit)? = null, - content: TransformingLazyColumnScope.(TransformationSpec) -> Unit, -) { - val listState = rememberTransformingLazyColumnState() - val transformation = rememberTransformationSpec() - AppScaffold { - if (edgeButton == null) { - ScreenScaffold(scrollState = listState) { padding -> - TransformingLazyColumn(state = listState, contentPadding = padding) { - content(transformation) - } - } - } else { - ScreenScaffold( - scrollState = listState, - edgeButton = { edgeButton() }, - ) { padding -> - TransformingLazyColumn(state = listState, contentPadding = padding) { - content(transformation) - } - } - } - } -} - -@Composable -internal fun TransformingLazyColumnItemScope.WearPageTitle(label: String, transformation: TransformationSpec) { - ListHeader( - modifier = Modifier.fillMaxWidth().transformedHeight(this, transformation), - transformation = SurfaceTransformation(transformation), - ) { - Text(label, color = MaterialTheme.colorScheme.onBackground) - } -} - -@Composable -internal fun TransformingLazyColumnItemScope.WearSectionTitle(label: String, transformation: TransformationSpec) { - ListHeader( - modifier = Modifier.fillMaxWidth().transformedHeight(this, transformation), - transformation = SurfaceTransformation(transformation), - ) { - Text(label, color = MaterialTheme.colorScheme.primary) - } -} - @Composable internal fun TransformingLazyColumnItemScope.WearNavigationRow( label: String, @@ -97,11 +36,7 @@ internal fun TransformingLazyColumnItemScope.WearNavigationRow( secondaryLabel = detail?.let { { Text(it) } }, icon = icon?.let { { - Icon( - painter = painterResource(it), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) + Icon(painterResource(it), null, tint = MaterialTheme.colorScheme.primary) } }, onClick = onClick, @@ -129,11 +64,7 @@ internal fun TransformingLazyColumnItemScope.WearCheckboxRow( secondaryLabel = detail?.let { { Text(it) } }, icon = icon?.let { { - Icon( - painter = painterResource(it), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - ) + Icon(painterResource(it), null, tint = MaterialTheme.colorScheme.primary) } }, colors = CheckboxButtonDefaults.checkboxButtonColors( @@ -189,53 +120,3 @@ internal fun TransformingLazyColumnItemScope.WearSwitchRow( transformation = SurfaceTransformation(transformation), ) } - -@Composable -internal fun TransformingLazyColumnItemScope.WearPrimaryButton( - label: String, - transformation: TransformationSpec, - enabled: Boolean = true, - onClick: () -> Unit, -) { - Button( - onClick = onClick, - enabled = enabled, - modifier = Modifier.fillMaxWidth().transformedHeight(this, transformation), - transformation = SurfaceTransformation(transformation), - ) { - Text(label) - } -} - -@Composable -internal fun TransformingLazyColumnItemScope.WearSecondaryButton( - label: String, - transformation: TransformationSpec, - onClick: () -> Unit, -) { - OutlinedButton( - onClick = onClick, - modifier = Modifier - .padding(horizontal = 36.dp) - .fillMaxWidth() - .transformedHeight(this, transformation), - transformation = SurfaceTransformation(transformation), - ) { - Text( - text = label, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - ) - } -} - -@Composable -internal fun WearPrimaryEdgeButton(label: String, enabled: Boolean = true, onClick: () -> Unit) { - EdgeButton( - onClick = onClick, - enabled = enabled, - buttonSize = EdgeButtonSize.Small, - ) { - Text(label) - } -} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveComponents.kt new file mode 100644 index 0000000..48abaa2 --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveComponents.kt @@ -0,0 +1,48 @@ +package com.tomasrepcik.sensorbox.presentation.dashboard + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.ButtonDefaults +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.Text +import com.tomasrepcik.sensorbox.R + +@Composable +internal fun WearRecordingStatus(selectedCount: Int, keepsDisplayOn: Boolean) { + Text( + text = stringResource(R.string.recording), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(18.dp)) + Text( + text = pluralStringResource(R.plurals.source_count, selectedCount, selectedCount), + style = MaterialTheme.typography.displaySmall, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(if (keepsDisplayOn) R.string.display_stays_on else R.string.display_may_sleep), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +internal fun WearStopRecordingButton(onStop: () -> Unit) { + Button( + onClick = onStop, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(stringResource(R.string.stop_and_save)) + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveScreen.kt new file mode 100644 index 0000000..942e1f3 --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearActiveScreen.kt @@ -0,0 +1,35 @@ +package com.tomasrepcik.sensorbox.presentation.dashboard + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.AppScaffold +import androidx.wear.compose.material3.ScreenScaffold + +@Composable +internal fun WearActiveScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { + val selectedCount = state.selectedSensorIds.size + if (state.includesGps) 1 else 0 + AppScaffold { + ScreenScaffold { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + WearRecordingStatus( + selectedCount = selectedCount, + keepsDisplayOn = state.preferences.display.keepWearDisplayOn, + ) + Spacer(Modifier.height(22.dp)) + WearStopRecordingButton { accept(WearDashboardIntent.StopRecording) } + } + } + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreen.kt new file mode 100644 index 0000000..27472ce --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearAppErrorScreen.kt @@ -0,0 +1,60 @@ +package com.tomasrepcik.sensorbox.presentation.dashboard + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.AppScaffold +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.core.error.AppErrorCode + +@Composable +internal fun WearAppErrorScreen(errorCode: AppErrorCode, onDismiss: () -> Unit) { + AppScaffold { + ScreenScaffold { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 18.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.app_error_title), + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(errorCode.messageResource()), + modifier = Modifier.padding(vertical = 8.dp), + textAlign = TextAlign.Center, + ) + Button(onClick = onDismiss) { Text(stringResource(R.string.close)) } + } + } + } +} + +private fun AppErrorCode.messageResource(): Int = when (this) { + AppErrorCode.CONNECTIVITY -> R.string.app_error_connectivity + + AppErrorCode.PERMISSION -> R.string.app_error_permission + + AppErrorCode.RECORDING -> R.string.app_error_recording + + AppErrorCode.STORAGE -> R.string.app_error_storage + + AppErrorCode.PREFERENCES, + AppErrorCode.EXTERNAL_ACTION, + AppErrorCode.VALIDATION, + AppErrorCode.TIMEOUT, + AppErrorCode.CONFLICT, + AppErrorCode.UNKNOWN, + -> R.string.app_error_unknown +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardComponents.kt new file mode 100644 index 0000000..dd462c1 --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardComponents.kt @@ -0,0 +1,35 @@ +package com.tomasrepcik.sensorbox.presentation.dashboard + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnItemScope +import androidx.wear.compose.material3.lazy.TransformationSpec +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.presentation.WearSecondaryButton + +@Composable +internal fun TransformingLazyColumnItemScope.WearBackButton( + transformation: TransformationSpec, + accept: (WearDashboardIntent) -> Unit, +) { + WearSecondaryButton(stringResource(R.string.back), transformation) { + accept(WearDashboardIntent.Back) + } +} + +@Composable +internal fun wearMessageText(message: WearDashboardMessage): String = when (message) { + WearDashboardMessage.PickSource -> stringResource(R.string.message_pick_source) + + WearDashboardMessage.PermissionRequired -> stringResource(R.string.message_permission_required) + + WearDashboardMessage.SensorUnavailable -> stringResource(R.string.message_sensor_unavailable) + + WearDashboardMessage.Syncing -> stringResource(R.string.syncing) + + WearDashboardMessage.SyncFailed -> stringResource(R.string.message_sync_failed) + + is WearDashboardMessage.FilesSent -> + pluralStringResource(R.plurals.message_files_sent, message.count, message.count) +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardContract.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardContract.kt index 4d8cb4b..a8f4ef9 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardContract.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardContract.kt @@ -1,9 +1,10 @@ package com.tomasrepcik.sensorbox.presentation.dashboard +import com.tomasrepcik.sensorbox.core.error.AppErrorCode import com.tomasrepcik.sensorbox.core.preferences.AppPreferences import com.tomasrepcik.sensorbox.domain.sensors.WatchSensorDescriptor import com.tomasrepcik.sensorbox.presentation.menu.WearMenuDestination -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState enum class WearRoute { MENU, RECORD, LIVE, SETTINGS, ACTIVE } @@ -13,10 +14,12 @@ data class WearDashboardState( val selectedSensorIds: Set = emptySet(), val includesGps: Boolean = false, val liveSensorType: Int? = null, - val latestValue: Float? = null, + val liveSamples: List = emptyList(), val preferences: AppPreferences = AppPreferences(), val isSyncing: Boolean = false, + val isWaitingForPermissions: Boolean = false, val message: WearDashboardMessage? = null, + val visibleFailureCode: AppErrorCode? = null, ) sealed interface WearDashboardMessage { @@ -42,6 +45,7 @@ sealed interface WearDashboardIntent { data object ToggleWakeLock : WearDashboardIntent data object ToggleDisplay : WearDashboardIntent data object SyncMeasurements : WearDashboardIntent + data object DismissFailure : WearDashboardIntent } sealed interface WearDashboardEffect { @@ -72,7 +76,7 @@ object WearDashboardReducer { private fun WearDashboardState.selectLiveSensor(sensorType: Int) = copy( route = WearRoute.LIVE, liveSensorType = sensorType, - latestValue = null, + liveSamples = emptyList(), ) private fun WearDashboardState.toggleSensor(sensorType: Int): WearDashboardState { diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardScreen.kt index 66db328..54b4df0 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardScreen.kt @@ -1,315 +1,20 @@ package com.tomasrepcik.sensorbox.presentation.dashboard -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -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.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -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 -import androidx.wear.compose.foundation.lazy.TransformingLazyColumnItemScope -import androidx.wear.compose.foundation.lazy.TransformingLazyColumnScope -import androidx.wear.compose.material3.AppScaffold -import androidx.wear.compose.material3.Button -import androidx.wear.compose.material3.ButtonDefaults -import androidx.wear.compose.material3.CircularProgressIndicator -import androidx.wear.compose.material3.MaterialTheme -import androidx.wear.compose.material3.OutlinedButton -import androidx.wear.compose.material3.ScreenScaffold -import androidx.wear.compose.material3.Text -import androidx.wear.compose.material3.lazy.TransformationSpec -import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost -import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer -import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer -import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart -import com.tomasrepcik.sensorbox.R -import com.tomasrepcik.sensorbox.core.format.ValueFormats -import com.tomasrepcik.sensorbox.domain.sensors.WatchSensorDescriptor -import com.tomasrepcik.sensorbox.presentation.WearCheckboxRow -import com.tomasrepcik.sensorbox.presentation.WearListScreen -import com.tomasrepcik.sensorbox.presentation.WearNavigationRow -import com.tomasrepcik.sensorbox.presentation.WearPageTitle -import com.tomasrepcik.sensorbox.presentation.WearPrimaryButton -import com.tomasrepcik.sensorbox.presentation.WearPrimaryEdgeButton -import com.tomasrepcik.sensorbox.presentation.WearRadioRow -import com.tomasrepcik.sensorbox.presentation.WearSecondaryButton -import com.tomasrepcik.sensorbox.presentation.WearSectionTitle -import com.tomasrepcik.sensorbox.presentation.WearSwitchRow import com.tomasrepcik.sensorbox.presentation.menu.WearMenuScreen import com.tomasrepcik.sensorbox.presentation.menu.WearMenuState @Composable -fun WearDashboardScreen( - state: WearDashboardState, - chartModelProducer: CartesianChartModelProducer, - accept: (WearDashboardIntent) -> Unit, -) { +fun WearDashboardScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { + state.visibleFailureCode?.let { failureCode -> + WearAppErrorScreen(failureCode) { accept(WearDashboardIntent.DismissFailure) } + return + } when (state.route) { WearRoute.MENU -> WearMenuScreen(WearMenuState()) { accept(WearDashboardIntent.Open(it)) } WearRoute.RECORD -> WearRecordScreen(state, accept) - WearRoute.LIVE -> WearLiveScreen(state, chartModelProducer, accept) + WearRoute.LIVE -> WearLiveScreen(state, accept) WearRoute.SETTINGS -> WearSettingsScreen(state, accept) WearRoute.ACTIVE -> WearActiveScreen(state, accept) } } - -@Composable -private fun WearRecordScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { - val selectedCount = state.selectedSensorIds.size + if (state.includesGps) 1 else 0 - WearListScreen( - edgeButton = { - WearPrimaryEdgeButton( - label = pluralStringResource(R.plurals.start_source_count, selectedCount, selectedCount), - enabled = selectedCount > 0, - onClick = { accept(WearDashboardIntent.StartRecording) }, - ) - }, - ) { transformation -> - item { WearPageTitle(stringResource(R.string.sources), transformation) } - item { - WearCheckboxRow( - label = stringResource(R.string.gps), - checked = state.includesGps, - icon = R.drawable.ic_location, - transformation = transformation, - onCheckedChange = { accept(WearDashboardIntent.ToggleGps) }, - ) - } - state.sensors.forEach { sensor -> - item { - WearCheckboxRow( - label = sensor.name, - detail = sensor.vendor, - checked = sensor.type in state.selectedSensorIds, - icon = R.drawable.ic_sensor, - transformation = transformation, - onCheckedChange = { accept(WearDashboardIntent.ToggleSensor(sensor.type)) }, - ) - } - } - state.message?.let { message -> - item { WearSectionTitle(wearMessageText(message), transformation) } - } - item { WearBackButton(transformation, accept) } - } -} - -@Composable -private fun WearLiveScreen( - state: WearDashboardState, - chartModelProducer: CartesianChartModelProducer, - accept: (WearDashboardIntent) -> Unit, -) { - val selected = state.sensors.firstOrNull { it.type == state.liveSensorType } - if (selected == null) { - WearSensorPicker(state.sensors, accept) - return - } - AppScaffold { - ScreenScaffold { padding -> - Column( - modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 18.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text( - text = selected.name, - style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center, - maxLines = 2, - ) - Spacer(Modifier.height(4.dp)) - Text( - text = state.latestValue?.let(ValueFormats::decimal) ?: stringResource(R.string.waiting), - style = MaterialTheme.typography.displaySmall, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(8.dp)) - CartesianChartHost( - chart = rememberCartesianChart(rememberLineCartesianLayer()), - modelProducer = chartModelProducer, - modifier = Modifier.fillMaxWidth().height(82.dp), - ) - Spacer(Modifier.height(8.dp)) - OutlinedButton( - onClick = { accept(WearDashboardIntent.Back) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.back)) - } - } - } - } -} - -@Composable -private fun WearSensorPicker(sensors: List, accept: (WearDashboardIntent) -> Unit) { - WearListScreen { transformation -> - item { WearPageTitle(stringResource(R.string.live_values), transformation) } - sensors.forEach { sensor -> - item { - WearNavigationRow( - label = sensor.name, - detail = sensor.vendor, - icon = R.drawable.ic_sensor, - transformation = transformation, - ) { accept(WearDashboardIntent.ObserveSensor(sensor.type)) } - } - } - item { WearBackButton(transformation, accept) } - } -} - -@Composable -private fun WearSettingsScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { - WearListScreen { transformation -> - item { WearPageTitle(stringResource(R.string.activity_settings), transformation) } - preferenceItems(state, transformation, accept) - item { WearSectionTitle(stringResource(R.string.transfer), transformation) } - item { - WearPrimaryButton( - label = stringResource(if (state.isSyncing) R.string.syncing else R.string.sync_recordings), - transformation = transformation, - enabled = !state.isSyncing, - ) { accept(WearDashboardIntent.SyncMeasurements) } - } - if (state.isSyncing) { - item { - CircularProgressIndicator( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - ) - } - } - state.message?.let { message -> - item { WearSectionTitle(wearMessageText(message), transformation) } - } - item { WearBackButton(transformation, accept) } - } -} - -private fun TransformingLazyColumnScope.preferenceItems( - state: WearDashboardState, - transformation: TransformationSpec, - accept: (WearDashboardIntent) -> Unit, -) { - item { WearSectionTitle(stringResource(R.string.sensor_sampling), transformation) } - listOf( - R.string.sampling_fastest, - R.string.sampling_game, - R.string.sampling_ui, - R.string.sampling_normal, - ).forEachIndexed { index, label -> - item { - WearRadioRow( - label = stringResource(label), - selected = state.preferences.recording.sensorSamplingPeriod == index, - transformation = transformation, - ) { accept(WearDashboardIntent.SetSamplingPeriod(index)) } - } - } - item { WearSectionTitle(stringResource(R.string.recording_options), transformation) } - item { - WearSwitchRow( - label = stringResource(R.string.stop_on_low_battery), - checked = state.preferences.recording.stopRecordingOnLowBattery, - transformation = transformation, - onCheckedChange = { accept(WearDashboardIntent.ToggleBatteryRestriction) }, - ) - } - item { - WearSwitchRow( - label = stringResource(R.string.wake_lock), - checked = state.preferences.recording.useWakeLock, - transformation = transformation, - onCheckedChange = { accept(WearDashboardIntent.ToggleWakeLock) }, - ) - } - item { - WearSwitchRow( - label = stringResource(R.string.keep_display_on), - checked = state.preferences.display.keepWearDisplayOn, - transformation = transformation, - onCheckedChange = { accept(WearDashboardIntent.ToggleDisplay) }, - ) - } -} - -@Composable -private fun WearActiveScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { - val selectedCount = state.selectedSensorIds.size + if (state.includesGps) 1 else 0 - AppScaffold { - ScreenScaffold { padding -> - Column( - modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 28.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text( - text = stringResource(R.string.recording), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - Spacer(Modifier.height(18.dp)) - Text( - text = pluralStringResource(R.plurals.source_count, selectedCount, selectedCount), - style = MaterialTheme.typography.displaySmall, - ) - Spacer(Modifier.height(4.dp)) - Text( - text = stringResource( - if (state.preferences.display.keepWearDisplayOn) { - R.string.display_stays_on - } else { - R.string.display_may_sleep - }, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(22.dp)) - Button( - onClick = { accept(WearDashboardIntent.StopRecording) }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Text(stringResource(R.string.stop_and_save)) - } - } - } - } -} - -@Composable -private fun TransformingLazyColumnItemScope.WearBackButton( - transformation: TransformationSpec, - accept: (WearDashboardIntent) -> Unit, -) { - WearSecondaryButton(stringResource(R.string.back), transformation) { - accept(WearDashboardIntent.Back) - } -} - -@Composable -private fun wearMessageText(message: WearDashboardMessage): String = when (message) { - WearDashboardMessage.PickSource -> stringResource(R.string.message_pick_source) - - WearDashboardMessage.PermissionRequired -> stringResource(R.string.message_permission_required) - - WearDashboardMessage.SensorUnavailable -> stringResource(R.string.message_sensor_unavailable) - - WearDashboardMessage.Syncing -> stringResource(R.string.syncing) - - WearDashboardMessage.SyncFailed -> stringResource(R.string.message_sync_failed) - - is WearDashboardMessage.FilesSent -> - pluralStringResource(R.plurals.message_files_sent, message.count, message.count) -} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardViewModel.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardViewModel.kt index 1022a09..9bc6605 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardViewModel.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardViewModel.kt @@ -2,10 +2,9 @@ package com.tomasrepcik.sensorbox.presentation.dashboard import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer -import com.patrykandpatrick.vico.compose.cartesian.data.lineModel import com.tomasrepcik.sensorbox.core.error.AppError import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppFailureStore import com.tomasrepcik.sensorbox.core.error.AppResult import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesIntent import com.tomasrepcik.sensorbox.core.preferences.AppPreferencesRepository @@ -15,7 +14,7 @@ import com.tomasrepcik.sensorbox.domain.sensors.GetWatchSensorsUseCase import com.tomasrepcik.sensorbox.domain.sensors.ObserveSensorValuesUseCase import com.tomasrepcik.sensorbox.domain.sync.SyncWatchMeasurementsUseCase import com.tomasrepcik.sensorbox.presentation.menu.WearMenuDestination -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionStore +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionStore import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel @@ -35,38 +34,56 @@ class WearDashboardViewModel @Inject constructor( private val preferencesRepository: AppPreferencesRepository, private val sessionStore: RecordingSessionStore, private val syncMeasurements: SyncWatchMeasurementsUseCase, + private val appFailures: AppFailureStore, ) : ViewModel() { private val mutableState = MutableStateFlow(WearDashboardState(sensors = getSensors())) private val mutableEffects = Channel(Channel.BUFFERED) private var sensorJob: Job? = null - private var pendingStart = false val state = mutableState.asStateFlow() val effects = mutableEffects.receiveAsFlow() - val chartModelProducer = CartesianChartModelProducer() init { observePreferences() observeSession() + observeRecordingFailures() + observeFailures() } fun accept(intent: WearDashboardIntent) { mutableState.value = WearDashboardReducer.reduce(mutableState.value, intent) + intent.toPreferenceIntent()?.let { + updatePreference(it) + return + } when (intent) { is WearDashboardIntent.Open -> handleDestination(intent.destination) WearDashboardIntent.StartRecording -> requestRecordingStart() is WearDashboardIntent.PermissionsResolved -> handlePermissionResult(intent.granted) WearDashboardIntent.StopRecording -> recordingControl.stop().showFailure() is WearDashboardIntent.ObserveSensor -> observeLiveSensor(intent.sensorType) - is WearDashboardIntent.SetSamplingPeriod -> setSamplingPeriod(intent.index) - WearDashboardIntent.ToggleBatteryRestriction -> toggleBatteryRestriction() - WearDashboardIntent.ToggleWakeLock -> toggleWakeLock() - WearDashboardIntent.ToggleDisplay -> toggleDisplay() WearDashboardIntent.SyncMeasurements -> startSync() + WearDashboardIntent.DismissFailure -> appFailures.dismiss() else -> Unit } } + private fun WearDashboardIntent.toPreferenceIntent(): AppPreferencesIntent? = when (this) { + is WearDashboardIntent.SetSamplingPeriod -> AppPreferencesIntent.SetSensorSamplingPeriod(index) + + WearDashboardIntent.ToggleBatteryRestriction -> AppPreferencesIntent.SetStopOnLowBattery( + !mutableState.value.preferences.recording.stopRecordingOnLowBattery, + ) + + WearDashboardIntent.ToggleWakeLock -> + AppPreferencesIntent.SetWakeLock(!mutableState.value.preferences.recording.useWakeLock) + + WearDashboardIntent.ToggleDisplay -> + AppPreferencesIntent.SetKeepWearDisplayOn(!mutableState.value.preferences.display.keepWearDisplayOn) + + else -> null + } + private fun handleDestination(destination: WearMenuDestination) { when (destination) { WearMenuDestination.PHONE_INFO -> mutableEffects.trySend(WearDashboardEffect.OpenPhone) @@ -91,14 +108,14 @@ class WearDashboardViewModel @Inject constructor( } private fun requestPermissions(permissions: Set) { - pendingStart = true + mutableState.value = mutableState.value.copy(isWaitingForPermissions = true) mutableEffects.trySend(WearDashboardEffect.RequestPermissions(permissions)) } private fun handlePermissionResult(granted: Boolean) { - if (granted && pendingStart) startRecording() + if (granted && mutableState.value.isWaitingForPermissions) startRecording() if (!granted) mutableState.value = mutableState.value.copy(message = WearDashboardMessage.PermissionRequired) - pendingStart = false + mutableState.value = mutableState.value.copy(isWaitingForPermissions = false) } private fun startRecording() { @@ -111,7 +128,7 @@ class WearDashboardViewModel @Inject constructor( sensorJob = viewModelScope.launch { observeSensorValues(sensorType) .catch { error -> - AppError.from(AppErrorCode.RECORDING, "Observe live sensor", error) + appFailures.show(AppError.from(AppErrorCode.RECORDING, "Observe live sensor", error)) showSensorError() } .collect(::publishSensorValue) @@ -119,10 +136,8 @@ class WearDashboardViewModel @Inject constructor( } private suspend fun publishSensorValue(value: Float) { - val samples = sampleBuffer + value - sampleBuffer = samples.takeLast(MAX_SAMPLES) - mutableState.value = mutableState.value.copy(latestValue = value, message = null) - chartModelProducer.runTransaction { lineModel { series(sampleBuffer) } } + val samples = (mutableState.value.liveSamples + value).takeLast(MAX_SAMPLES) + mutableState.value = mutableState.value.copy(liveSamples = samples, message = null) } private fun showSensorError() { @@ -135,7 +150,7 @@ class WearDashboardViewModel @Inject constructor( onSuccess = { preferences -> mutableState.value = mutableState.value.copy(preferences = preferences) }, - onFailure = { showOperationFailure() }, + onFailure = ::showOperationFailure, ) } } @@ -146,28 +161,16 @@ class WearDashboardViewModel @Inject constructor( } } + private fun observeRecordingFailures() = viewModelScope.launch { + sessionStore.events.collect { event -> + event.result.errorOrNull()?.let(appFailures::show) + } + } + private fun updatePreference(intent: AppPreferencesIntent) { viewModelScope.launch { preferencesRepository.dispatch(intent).showFailure() } } - private fun setSamplingPeriod(index: Int) = updatePreference( - AppPreferencesIntent.SetSensorSamplingPeriod(index), - ) - - private fun toggleBatteryRestriction() = updatePreference( - AppPreferencesIntent.SetStopOnLowBattery( - !mutableState.value.preferences.recording.stopRecordingOnLowBattery, - ), - ) - - private fun toggleWakeLock() = updatePreference( - AppPreferencesIntent.SetWakeLock(!mutableState.value.preferences.recording.useWakeLock), - ) - - private fun toggleDisplay() = updatePreference( - AppPreferencesIntent.SetKeepWearDisplayOn(!mutableState.value.preferences.display.keepWearDisplayOn), - ) - private fun startSync() { if (mutableState.value.isSyncing) return mutableState.value = mutableState.value.copy(isSyncing = true, message = WearDashboardMessage.Syncing) @@ -175,22 +178,34 @@ class WearDashboardViewModel @Inject constructor( val result = syncMeasurements() val message = result.fold( onSuccess = WearDashboardMessage::FilesSent, - onFailure = { WearDashboardMessage.SyncFailed }, + onFailure = { error -> + appFailures.show(error) + WearDashboardMessage.SyncFailed + }, ) mutableState.value = mutableState.value.copy(isSyncing = false, message = message) } } - private var sampleBuffer: List = emptyList() - private fun AppResult<*>.showFailure() { - if (isFailure) showOperationFailure() + errorOrNull()?.let(::showOperationFailure) } - private fun showOperationFailure() { + private fun showOperationFailure(error: AppError) { + appFailures.show(error) mutableState.value = mutableState.value.copy(message = WearDashboardMessage.SyncFailed) } + private fun observeFailures() = viewModelScope.launch { + appFailures.visibleFailure.collect { failure -> + mutableState.value = mutableState.value.copy(visibleFailureCode = failure?.code) + } + } + + fun reportFailure(error: AppError) { + appFailures.show(error) + } + private companion object { const val MAX_SAMPLES = 60 } diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveComponents.kt new file mode 100644 index 0000000..2a2f969 --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveComponents.kt @@ -0,0 +1,104 @@ +package com.tomasrepcik.sensorbox.presentation.dashboard + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material3.AppScaffold +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.OutlinedButton +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.core.format.ValueFormats +import com.tomasrepcik.sensorbox.domain.sensors.WatchSensorDescriptor +import com.tomasrepcik.sensorbox.presentation.WearListScreen +import com.tomasrepcik.sensorbox.presentation.WearNavigationRow +import com.tomasrepcik.sensorbox.presentation.WearPageTitle + +@Composable +internal fun WearLiveSensor( + sensor: WatchSensorDescriptor, + latestValue: Float?, + chartModelProducer: CartesianChartModelProducer, + accept: (WearDashboardIntent) -> Unit, +) { + AppScaffold { + ScreenScaffold { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 18.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + WearLiveReading(sensor.name, latestValue) + Spacer(Modifier.height(8.dp)) + WearLiveChart(chartModelProducer) + Spacer(Modifier.height(8.dp)) + WearLiveBackButton { accept(WearDashboardIntent.Back) } + } + } + } +} + +@Composable +private fun WearLiveReading(sensorName: String, latestValue: Float?) { + Text( + text = sensorName, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + maxLines = 2, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = latestValue?.let(ValueFormats::decimal) ?: stringResource(R.string.waiting), + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.primary, + ) +} + +@Composable +private fun WearLiveChart(chartModelProducer: CartesianChartModelProducer) { + CartesianChartHost( + chart = rememberCartesianChart(rememberLineCartesianLayer()), + modelProducer = chartModelProducer, + modifier = Modifier.fillMaxWidth().height(82.dp), + ) +} + +@Composable +private fun WearLiveBackButton(onBack: () -> Unit) { + OutlinedButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.back)) + } +} + +@Composable +internal fun WearSensorPicker(sensors: List, accept: (WearDashboardIntent) -> Unit) { + WearListScreen { transformation -> + item { WearPageTitle(stringResource(R.string.live_values), transformation) } + sensors.forEach { sensor -> + item { + WearNavigationRow( + label = sensor.name, + detail = sensor.vendor, + icon = R.drawable.ic_sensor, + transformation = transformation, + ) { accept(WearDashboardIntent.ObserveSensor(sensor.type)) } + } + } + item { WearBackButton(transformation, accept) } + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveScreen.kt new file mode 100644 index 0000000..9fac904 --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearLiveScreen.kt @@ -0,0 +1,21 @@ +package com.tomasrepcik.sensorbox.presentation.dashboard + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.compose.cartesian.data.lineModel + +@Composable +internal fun WearLiveScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { + val chartModelProducer = remember { CartesianChartModelProducer() } + LaunchedEffect(state.liveSamples) { + chartModelProducer.runTransaction { lineModel { series(state.liveSamples) } } + } + val selectedSensor = state.sensors.firstOrNull { it.type == state.liveSensorType } + if (selectedSensor == null) { + WearSensorPicker(state.sensors, accept) + } else { + WearLiveSensor(selectedSensor, state.liveSamples.lastOrNull(), chartModelProducer, accept) + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordComponents.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordComponents.kt new file mode 100644 index 0000000..5bbe3f8 --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordComponents.kt @@ -0,0 +1,52 @@ +package com.tomasrepcik.sensorbox.presentation.dashboard + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnItemScope +import androidx.wear.compose.material3.lazy.TransformationSpec +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.domain.sensors.WatchSensorDescriptor +import com.tomasrepcik.sensorbox.presentation.WearCheckboxRow +import com.tomasrepcik.sensorbox.presentation.WearPrimaryEdgeButton + +@Composable +internal fun WearRecordingStartButton(selectedCount: Int, accept: (WearDashboardIntent) -> Unit) { + WearPrimaryEdgeButton( + label = pluralStringResource(R.plurals.start_source_count, selectedCount, selectedCount), + enabled = selectedCount > 0, + onClick = { accept(WearDashboardIntent.StartRecording) }, + ) +} + +@Composable +internal fun TransformingLazyColumnItemScope.WearGpsSourceRow( + selected: Boolean, + transformation: TransformationSpec, + onToggle: (Boolean) -> Unit, +) { + WearCheckboxRow( + label = stringResource(R.string.gps), + checked = selected, + icon = R.drawable.ic_location, + transformation = transformation, + onCheckedChange = onToggle, + ) +} + +@Composable +internal fun TransformingLazyColumnItemScope.WearSensorSourceRow( + sensor: WatchSensorDescriptor, + selected: Boolean, + transformation: TransformationSpec, + onToggle: (Boolean) -> Unit, +) { + WearCheckboxRow( + label = sensor.name, + detail = sensor.vendor, + checked = selected, + icon = R.drawable.ic_sensor, + transformation = transformation, + onCheckedChange = onToggle, + ) +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordScreen.kt new file mode 100644 index 0000000..100e207 --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearRecordScreen.kt @@ -0,0 +1,32 @@ +package com.tomasrepcik.sensorbox.presentation.dashboard + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.presentation.WearListScreen +import com.tomasrepcik.sensorbox.presentation.WearPageTitle +import com.tomasrepcik.sensorbox.presentation.WearSectionTitle + +@Composable +internal fun WearRecordScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { + val selectedCount = state.selectedSensorIds.size + if (state.includesGps) 1 else 0 + WearListScreen( + edgeButton = { WearRecordingStartButton(selectedCount, accept) }, + ) { transformation -> + item { WearPageTitle(stringResource(R.string.sources), transformation) } + item { WearGpsSourceRow(state.includesGps, transformation) { accept(WearDashboardIntent.ToggleGps) } } + state.sensors.forEach { sensor -> + item { + WearSensorSourceRow( + sensor = sensor, + selected = sensor.type in state.selectedSensorIds, + transformation = transformation, + ) { accept(WearDashboardIntent.ToggleSensor(sensor.type)) } + } + } + state.message?.let { message -> + item { WearSectionTitle(wearMessageText(message), transformation) } + } + item { WearBackButton(transformation, accept) } + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearSettingsScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearSettingsScreen.kt new file mode 100644 index 0000000..c0f5b3f --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearSettingsScreen.kt @@ -0,0 +1,104 @@ +package com.tomasrepcik.sensorbox.presentation.dashboard + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.TransformingLazyColumnScope +import androidx.wear.compose.material3.CircularProgressIndicator +import androidx.wear.compose.material3.lazy.TransformationSpec +import com.tomasrepcik.sensorbox.R +import com.tomasrepcik.sensorbox.presentation.WearListScreen +import com.tomasrepcik.sensorbox.presentation.WearPageTitle +import com.tomasrepcik.sensorbox.presentation.WearPrimaryButton +import com.tomasrepcik.sensorbox.presentation.WearRadioRow +import com.tomasrepcik.sensorbox.presentation.WearSectionTitle +import com.tomasrepcik.sensorbox.presentation.WearSwitchRow + +@Composable +internal fun WearSettingsScreen(state: WearDashboardState, accept: (WearDashboardIntent) -> Unit) { + WearListScreen { transformation -> + item { WearPageTitle(stringResource(R.string.activity_settings), transformation) } + samplingPreferenceItems(state, transformation, accept) + recordingPreferenceItems(state, transformation, accept) + measurementTransferItems(state, transformation, accept) + state.message?.let { message -> + item { WearSectionTitle(wearMessageText(message), transformation) } + } + item { WearBackButton(transformation, accept) } + } +} + +private fun TransformingLazyColumnScope.samplingPreferenceItems( + state: WearDashboardState, + transformation: TransformationSpec, + accept: (WearDashboardIntent) -> Unit, +) { + item { WearSectionTitle(stringResource(R.string.sensor_sampling), transformation) } + listOf( + R.string.sampling_fastest, + R.string.sampling_game, + R.string.sampling_ui, + R.string.sampling_normal, + ).forEachIndexed { index, label -> + item { + WearRadioRow( + label = stringResource(label), + selected = state.preferences.recording.sensorSamplingPeriod == index, + transformation = transformation, + ) { accept(WearDashboardIntent.SetSamplingPeriod(index)) } + } + } +} + +private fun TransformingLazyColumnScope.recordingPreferenceItems( + state: WearDashboardState, + transformation: TransformationSpec, + accept: (WearDashboardIntent) -> Unit, +) { + item { WearSectionTitle(stringResource(R.string.recording_options), transformation) } + item { + WearSwitchRow( + label = stringResource(R.string.stop_on_low_battery), + checked = state.preferences.recording.stopRecordingOnLowBattery, + transformation = transformation, + onCheckedChange = { accept(WearDashboardIntent.ToggleBatteryRestriction) }, + ) + } + item { + WearSwitchRow( + label = stringResource(R.string.wake_lock), + checked = state.preferences.recording.useWakeLock, + transformation = transformation, + onCheckedChange = { accept(WearDashboardIntent.ToggleWakeLock) }, + ) + } + item { + WearSwitchRow( + label = stringResource(R.string.keep_display_on), + checked = state.preferences.display.keepWearDisplayOn, + transformation = transformation, + onCheckedChange = { accept(WearDashboardIntent.ToggleDisplay) }, + ) + } +} + +private fun TransformingLazyColumnScope.measurementTransferItems( + state: WearDashboardState, + transformation: TransformationSpec, + accept: (WearDashboardIntent) -> Unit, +) { + item { WearSectionTitle(stringResource(R.string.transfer), transformation) } + item { + WearPrimaryButton( + label = stringResource(if (state.isSyncing) R.string.syncing else R.string.sync_recordings), + transformation = transformation, + enabled = !state.isSyncing, + ) { accept(WearDashboardIntent.SyncMeasurements) } + } + if (state.isSyncing) { + item { CircularProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp)) } + } +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuContract.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuContract.kt index 548e86c..a321a29 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuContract.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuContract.kt @@ -1,17 +1,13 @@ package com.tomasrepcik.sensorbox.presentation.menu -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import com.tomasrepcik.sensorbox.R - -enum class WearMenuDestination(@StringRes val labelResource: Int, @DrawableRes val iconResource: Int) { - RECORD(R.string.activity_record, R.drawable.ic_record), - SYNC(R.string.sync_recordings, R.drawable.ic_sync), - LIVE_SENSOR(R.string.activity_view_sensor, R.drawable.ic_live), - PHONE_INFO(R.string.activity_info_phone, R.drawable.ic_phone), - SETTINGS(R.string.activity_settings, R.drawable.ic_settings), - PRIVACY(R.string.activity_privacy_policy, R.drawable.ic_privacy), - TERMS(R.string.activity_terms_of_use, R.drawable.ic_terms), +enum class WearMenuDestination { + RECORD, + SYNC, + LIVE_SENSOR, + PHONE_INFO, + SETTINGS, + PRIVACY, + TERMS, } data class WearMenuState(val destinations: List = WearMenuDestination.entries) diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreen.kt index 2c33b5b..5b11d23 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/menu/WearMenuScreen.kt @@ -1,5 +1,7 @@ package com.tomasrepcik.sensorbox.presentation.menu +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.tomasrepcik.sensorbox.R @@ -14,11 +16,33 @@ fun WearMenuScreen(state: WearMenuState, onDestinationSelected: (WearMenuDestina state.destinations.forEach { destination -> item { WearNavigationRow( - label = stringResource(destination.labelResource), - icon = destination.iconResource, + label = stringResource(destination.labelResource()), + icon = destination.iconResource(), transformation = transformation, ) { onDestinationSelected(destination) } } } } } + +@StringRes +private fun WearMenuDestination.labelResource(): Int = when (this) { + WearMenuDestination.RECORD -> R.string.activity_record + WearMenuDestination.SYNC -> R.string.sync_recordings + WearMenuDestination.LIVE_SENSOR -> R.string.activity_view_sensor + WearMenuDestination.PHONE_INFO -> R.string.activity_info_phone + WearMenuDestination.SETTINGS -> R.string.activity_settings + WearMenuDestination.PRIVACY -> R.string.activity_privacy_policy + WearMenuDestination.TERMS -> R.string.activity_terms_of_use +} + +@DrawableRes +private fun WearMenuDestination.iconResource(): Int = when (this) { + WearMenuDestination.RECORD -> R.drawable.ic_record + WearMenuDestination.SYNC -> R.drawable.ic_sync + WearMenuDestination.LIVE_SENSOR -> R.drawable.ic_live + WearMenuDestination.PHONE_INFO -> R.drawable.ic_phone + WearMenuDestination.SETTINGS -> R.drawable.ic_settings + WearMenuDestination.PRIVACY -> R.drawable.ic_privacy + WearMenuDestination.TERMS -> R.drawable.ic_terms +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchContract.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchContract.kt index abf5b15..68c620d 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchContract.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchContract.kt @@ -1,5 +1,7 @@ package com.tomasrepcik.sensorbox.presentation.phonelaunch +import com.tomasrepcik.sensorbox.core.error.AppErrorCode + enum class PhoneLaunchStatus { IDLE, PHONE_UNAVAILABLE, @@ -11,6 +13,7 @@ enum class PhoneLaunchStatus { data class PhoneLaunchState( val isPhoneConnected: Boolean = false, val status: PhoneLaunchStatus = PhoneLaunchStatus.IDLE, + val visibleFailureCode: AppErrorCode? = null, ) sealed interface PhoneLaunchIntent { @@ -19,6 +22,8 @@ sealed interface PhoneLaunchIntent { data class ConnectionChanged(val isConnected: Boolean) : PhoneLaunchIntent data class LaunchCompleted(val succeeded: Boolean) : PhoneLaunchIntent + + data object DismissFailure : PhoneLaunchIntent } sealed interface PhoneLaunchEffect { @@ -32,6 +37,7 @@ object PhoneLaunchReducer { is PhoneLaunchIntent.ConnectionChanged -> connectionChanged(state, intent.isConnected) is PhoneLaunchIntent.LaunchCompleted -> launchCompleted(state, intent.succeeded) PhoneLaunchIntent.LaunchRequested -> launchRequested(state) + PhoneLaunchIntent.DismissFailure -> PhoneLaunchNext(state.copy(visibleFailureCode = null)) } private fun connectionChanged(state: PhoneLaunchState, isConnected: Boolean) = PhoneLaunchNext( diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchScreen.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchScreen.kt index a6f44c2..38ece7c 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchScreen.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchScreen.kt @@ -7,9 +7,14 @@ import com.tomasrepcik.sensorbox.presentation.WearListScreen import com.tomasrepcik.sensorbox.presentation.WearPageTitle import com.tomasrepcik.sensorbox.presentation.WearPrimaryButton import com.tomasrepcik.sensorbox.presentation.WearSectionTitle +import com.tomasrepcik.sensorbox.presentation.dashboard.WearAppErrorScreen @Composable -fun PhoneLaunchScreen(state: PhoneLaunchState, onLaunchPhone: () -> Unit) { +fun PhoneLaunchScreen(state: PhoneLaunchState, onIntent: (PhoneLaunchIntent) -> Unit) { + state.visibleFailureCode?.let { code -> + WearAppErrorScreen(code) { onIntent(PhoneLaunchIntent.DismissFailure) } + return + } WearListScreen { transformation -> item { WearPageTitle(stringResource(R.string.activity_info_phone), transformation) } item { WearSectionTitle(stringResource(statusText(state.status)), transformation) } @@ -17,17 +22,9 @@ fun PhoneLaunchScreen(state: PhoneLaunchState, onLaunchPhone: () -> Unit) { WearPrimaryButton( label = stringResource(R.string.open_phone), transformation = transformation, - onClick = onLaunchPhone, + onClick = { onIntent(PhoneLaunchIntent.LaunchRequested) }, enabled = state.isPhoneConnected && state.status != PhoneLaunchStatus.LAUNCHING, ) } } } - -private fun statusText(status: PhoneLaunchStatus): Int = when (status) { - PhoneLaunchStatus.IDLE -> R.string.phone_ready - PhoneLaunchStatus.PHONE_UNAVAILABLE -> R.string.norespond - PhoneLaunchStatus.LAUNCHING -> R.string.open_module - PhoneLaunchStatus.SENT -> R.string.phone_opened - PhoneLaunchStatus.FAILED -> R.string.error_toast -} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchStatusText.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchStatusText.kt new file mode 100644 index 0000000..a662034 --- /dev/null +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchStatusText.kt @@ -0,0 +1,11 @@ +package com.tomasrepcik.sensorbox.presentation.phonelaunch + +import com.tomasrepcik.sensorbox.R + +internal fun statusText(status: PhoneLaunchStatus): Int = when (status) { + PhoneLaunchStatus.IDLE -> R.string.phone_ready + PhoneLaunchStatus.PHONE_UNAVAILABLE -> R.string.norespond + PhoneLaunchStatus.LAUNCHING -> R.string.open_module + PhoneLaunchStatus.SENT -> R.string.phone_opened + PhoneLaunchStatus.FAILED -> R.string.error_toast +} diff --git a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModel.kt b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModel.kt index ba8e889..b658f6f 100644 --- a/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModel.kt +++ b/wear/src/main/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModel.kt @@ -2,6 +2,9 @@ package com.tomasrepcik.sensorbox.presentation.phonelaunch import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.tomasrepcik.sensorbox.core.error.AppError +import com.tomasrepcik.sensorbox.core.error.AppErrorCode +import com.tomasrepcik.sensorbox.core.error.AppFailureStore import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_APP_CAPABILITY import com.tomasrepcik.sensorbox.wearoslib.WearOsConstants.PHONE_MESSAGE_PATH import com.tomasrepcik.sensorbox.wearoslib.connectivity.ObserveWearCapabilityUseCase @@ -12,6 +15,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch import javax.inject.Inject @@ -19,19 +23,35 @@ import javax.inject.Inject class PhoneLaunchViewModel @Inject constructor( private val observeWearCapability: ObserveWearCapabilityUseCase, private val sendWearCommand: SendWearCommandUseCase, + private val appFailures: AppFailureStore, ) : ViewModel() { private val mutableState = MutableStateFlow(PhoneLaunchState()) val state: StateFlow = mutableState.asStateFlow() init { viewModelScope.launch { - observeWearCapability(PHONE_APP_CAPABILITY).collect { connection -> + appFailures.visibleFailure.collect { error -> + mutableState.value = state.value.copy(visibleFailureCode = error?.code) + } + } + viewModelScope.launch { + observeWearCapability(PHONE_APP_CAPABILITY).catch { cause -> + appFailures.show( + AppError( + AppErrorCode.CONNECTIVITY, + "Observe phone connection", + "Phone connection observation failed", + cause, + ), + ) + }.collect { connection -> accept(PhoneLaunchIntent.ConnectionChanged(connection is WearConnection.Connected)) } } } fun accept(intent: PhoneLaunchIntent) { + if (intent == PhoneLaunchIntent.DismissFailure) appFailures.dismiss() val next = PhoneLaunchReducer.reduce(mutableState.value, intent) mutableState.value = next.state if (next.effect == PhoneLaunchEffect.SendLaunchMessage) { @@ -42,6 +62,7 @@ class PhoneLaunchViewModel @Inject constructor( private fun sendLaunchMessage() { viewModelScope.launch { val result = sendWearCommand(PHONE_APP_CAPABILITY, PHONE_MESSAGE_PATH, WearCommand.LaunchPhone) + result.errorOrNull()?.let(appFailures::show) accept(PhoneLaunchIntent.LaunchCompleted(result.isSuccess)) } } diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml index 69ec66b..0a1884a 100644 --- a/wear/src/main/res/values/strings.xml +++ b/wear/src/main/res/values/strings.xml @@ -1,5 +1,12 @@ + Close + Something went wrong + The device connection failed. The error was saved. + The permission could not be used. The error was saved. + The recording operation failed. The error was saved. + Local storage could not be used. The error was saved. + The operation failed. The error was saved. SensorBox Record diff --git a/wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt b/wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt index 8038dac..c5a6d6a 100644 --- a/wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt +++ b/wear/src/screenshotTest/kotlin/com/tomasrepcik/sensorbox/presentation/dashboard/ReadmeWearScreenshotPreviews.kt @@ -7,7 +7,6 @@ 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 @@ -15,7 +14,6 @@ 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.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer import com.tomasrepcik.sensorbox.core.preferences.AppPreferences import com.tomasrepcik.sensorbox.core.preferences.DisplayPreferences import com.tomasrepcik.sensorbox.core.preferences.RecordingPreferences @@ -76,7 +74,6 @@ private fun WearReadmeFrame(state: WearDashboardState) { Box(Modifier.fillMaxSize()) { WearDashboardScreen( state = state, - chartModelProducer = remember { CartesianChartModelProducer() }, accept = {}, ) Box( diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt index 2fcd87e..89aef5c 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/dashboard/WearDashboardReducerTest.kt @@ -2,7 +2,7 @@ package com.tomasrepcik.sensorbox.presentation.dashboard import com.tomasrepcik.sensorbox.core.testing.AppPreferencesFixtures import com.tomasrepcik.sensorbox.presentation.menu.WearMenuDestination -import com.tomasrepcik.sensorbox.sensorservices.session.RecordingSessionState +import com.tomasrepcik.sensorbox.recording.session.RecordingSessionState import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test diff --git a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModelTest.kt b/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModelTest.kt index 9f45d50..68cfa09 100644 --- a/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModelTest.kt +++ b/wear/src/test/java/com/tomasrepcik/sensorbox/presentation/phonelaunch/PhoneLaunchViewModelTest.kt @@ -1,5 +1,7 @@ package com.tomasrepcik.sensorbox.presentation.phonelaunch +import com.tomasrepcik.sensorbox.core.error.AppFailureStore +import com.tomasrepcik.sensorbox.core.error.DiagnosticLogger import com.tomasrepcik.sensorbox.testing.MainDispatcherRule import com.tomasrepcik.sensorbox.wearoslib.connectivity.FakeWearConnectionRepository import com.tomasrepcik.sensorbox.wearoslib.connectivity.ObserveWearCapabilityUseCase @@ -44,5 +46,6 @@ class PhoneLaunchViewModelTest { private fun createViewModel(repository: FakeWearConnectionRepository) = PhoneLaunchViewModel( ObserveWearCapabilityUseCase(repository), SendWearCommandUseCase(SendWearMessageUseCase(repository)), + AppFailureStore(DiagnosticLogger { }), ) }