diff --git a/app/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt b/app/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt index 3218fc8..13c8c47 100644 --- a/app/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt +++ b/app/src/main/java/com/motionapps/sensorbox/activities/MainActivity.kt @@ -2,6 +2,7 @@ package com.motionapps.sensorbox.activities import android.annotation.SuppressLint import android.content.ClipData +import android.content.ClipboardManager import android.content.Intent import android.net.Uri import android.os.Bundle @@ -16,60 +17,116 @@ import androidx.compose.runtime.getValue import androidx.core.content.FileProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.motionapps.sensorbox.R -import com.motionapps.sensorbox.core.error.AppDiagnostics -import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.DiagnosticsStore import com.motionapps.sensorbox.core.error.appResult import com.motionapps.sensorbox.core.error.flatMap -import com.motionapps.sensorbox.presentation.main.MainEffect import com.motionapps.sensorbox.presentation.main.MainViewModel +import com.motionapps.sensorbox.presentation.main.OnboardingEffect +import com.motionapps.sensorbox.presentation.main.OnboardingViewModel +import com.motionapps.sensorbox.presentation.main.RecordingEffect +import com.motionapps.sensorbox.presentation.main.RecordingViewModel import com.motionapps.sensorbox.presentation.main.SensorBoxApp +import com.motionapps.sensorbox.presentation.main.SettingsEffect +import com.motionapps.sensorbox.presentation.main.SettingsViewModel import com.motionapps.sensorbox.ui.theme.SensorBoxTheme import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { - private val viewModel: MainViewModel by viewModels() + @Inject + lateinit var diagnosticsStore: DiagnosticsStore + + private val mainViewModel: MainViewModel by viewModels() + private val onboardingViewModel: OnboardingViewModel by viewModels() + private val recordingViewModel: RecordingViewModel by viewModels() + private val settingsViewModel: SettingsViewModel by viewModels() + private var storageRequestOwner = StorageRequestOwner.RECORDING private val directoryPicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - viewModel.handleStorageResult(it.data) + when (storageRequestOwner) { + StorageRequestOwner.ONBOARDING -> onboardingViewModel.handleStorageResult(it.data) + StorageRequestOwner.RECORDING -> recordingViewModel.handleStorageResult(it.data) + } } private val permissionRequest = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { - viewModel.handlePermissionResult() + recordingViewModel.handlePermissionResult() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (intent.action == Intent.ACTION_VIEW_PERMISSION_USAGE) viewModel.showPrivacyRationale() + if (intent.action == Intent.ACTION_VIEW_PERMISSION_USAGE) mainViewModel.showPrivacyRationale() setContent { - val state by viewModel.state.collectAsStateWithLifecycle() - LaunchedEffect(viewModel) { viewModel.effects.collect(::handleEffect) } + val mainState by mainViewModel.state.collectAsStateWithLifecycle() + val onboardingState by onboardingViewModel.state.collectAsStateWithLifecycle() + val recordingState by recordingViewModel.state.collectAsStateWithLifecycle() + val settingsState by settingsViewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(onboardingViewModel) { + onboardingViewModel.effects.collect(::handleOnboardingEffect) + } + LaunchedEffect(recordingViewModel) { + recordingViewModel.effects.collect(::handleRecordingEffect) + } + LaunchedEffect(settingsViewModel) { + settingsViewModel.effects.collect(::handleSettingsEffect) + } SensorBoxTheme { - SensorBoxApp(state = state, onIntent = viewModel::accept) + SensorBoxApp( + mainState = mainState, + onboardingState = onboardingState, + recordingState = recordingState, + settingsState = settingsState, + onNavigate = mainViewModel::navigate, + onOnboardingIntent = onboardingViewModel::accept, + onRecordingIntent = recordingViewModel::accept, + onSettingsIntent = settingsViewModel::accept, + ) } } } - private fun handleEffect(effect: MainEffect) { + private fun handleOnboardingEffect(effect: OnboardingEffect) { when (effect) { - MainEffect.PickStorageDirectory -> appResult(AppError.Kind.EXTERNAL_ACTION, "Open storage picker") { - directoryPicker.launch(storageIntent()) - } - - MainEffect.OpenPrivacyPolicy -> openWebPage(getString(R.string.link_privacy_policy)) - - MainEffect.OpenTermsOfUse -> openWebPage(getString(R.string.link_terms)) - - MainEffect.RequestBatteryOptimizationExemption -> requestBatteryOptimizationExemption() + OnboardingEffect.PickStorageDirectory -> openStoragePicker(StorageRequestOwner.ONBOARDING) + OnboardingEffect.OpenPrivacyPolicy -> openWebPage(getString(R.string.link_privacy_policy)) + OnboardingEffect.OpenTermsOfUse -> openWebPage(getString(R.string.link_terms)) + OnboardingEffect.RequestBatteryOptimizationExemption -> requestBatteryOptimizationExemption() + is OnboardingEffect.Navigate -> mainViewModel.navigate(effect.route) + } + } - MainEffect.ShareDiagnosticsText -> shareDiagnosticsText() + private fun handleRecordingEffect(effect: RecordingEffect) { + when (effect) { + RecordingEffect.PickStorageDirectory -> openStoragePicker(StorageRequestOwner.RECORDING) - MainEffect.ShareDiagnosticsFile -> shareDiagnosticsFile() + is RecordingEffect.Navigate -> mainViewModel.navigate(effect.route) - is MainEffect.RequestPermissions -> appResult(AppError.Kind.PERMISSION, "Request app permissions") { + is RecordingEffect.RequestPermissions -> appResult(AppErrorCode.PERMISSION, "Request app permissions") { permissionRequest.launch(effect.permissions.toTypedArray()) } } } + private fun handleSettingsEffect(effect: SettingsEffect) { + when (effect) { + SettingsEffect.RequestBatteryOptimizationExemption -> requestBatteryOptimizationExemption() + SettingsEffect.ShareDiagnosticsText -> shareDiagnosticsText() + SettingsEffect.ShareDiagnosticsFile -> shareDiagnosticsFile() + is SettingsEffect.CopyDiagnosticsText -> copyDiagnostics(effect.text) + SettingsEffect.DiagnosticsCleared -> showToast(R.string.diagnostics_cleared) + is SettingsEffect.DiagnosticsFailed -> showToast(R.string.diagnostics_action_failed) + is SettingsEffect.Navigate -> mainViewModel.navigate(effect.route) + } + } + + private fun openStoragePicker(owner: StorageRequestOwner) { + storageRequestOwner = owner + appResult(AppErrorCode.EXTERNAL_ACTION, "Open storage picker") { + directoryPicker.launch(storageIntent()) + } + } + private fun openWebPage(url: String) { launchExternalIntent(Intent(Intent.ACTION_VIEW, Uri.parse(url)), "Open web page") } @@ -89,7 +146,7 @@ class MainActivity : ComponentActivity() { } private fun shareDiagnosticsText() { - AppDiagnostics.readText().fold( + diagnosticsStore.readText().fold( onSuccess = { diagnostics -> val intent = Intent(Intent.ACTION_SEND) .setType("text/plain") @@ -102,9 +159,9 @@ class MainActivity : ComponentActivity() { } private fun shareDiagnosticsFile() { - AppDiagnostics.exportFile().fold( + diagnosticsStore.exportFile().fold( onSuccess = { file -> - appResult(AppError.Kind.EXTERNAL_ACTION, "Prepare diagnostics 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") @@ -119,18 +176,33 @@ class MainActivity : ComponentActivity() { ) } - private fun launchShareIntent(intent: Intent): Result = + private fun launchShareIntent(intent: Intent): AppResult = launchExternalIntent(Intent.createChooser(intent, getString(R.string.diagnostics_share)), "Share diagnostics") - private fun launchExternalIntent(intent: Intent, operation: String): Result = appResult( - AppError.Kind.EXTERNAL_ACTION, + private fun launchExternalIntent(intent: Intent, operation: String): AppResult = appResult( + AppErrorCode.EXTERNAL_ACTION, operation, ) { startActivity(intent) } private fun showDiagnosticsShareFailure() { - Toast.makeText(this, R.string.diagnostics_share_failed, Toast.LENGTH_LONG).show() + showToast(R.string.diagnostics_share_failed) + } + + private fun copyDiagnostics(text: String) { + appResult(AppErrorCode.EXTERNAL_ACTION, "Copy diagnostics") { + getSystemService(ClipboardManager::class.java).setPrimaryClip( + ClipData.newPlainText(getString(R.string.diagnostics_title), text), + ) + }.fold( + onSuccess = { showToast(R.string.diagnostics_copied) }, + onFailure = { showToast(R.string.diagnostics_action_failed) }, + ) + } + + private fun showToast(message: Int) { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() } private fun storageIntent() = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { @@ -138,4 +210,9 @@ class MainActivity : ComponentActivity() { addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) } + + private enum class StorageRequestOwner { + ONBOARDING, + RECORDING, + } } diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainContract.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainContract.kt index 9b58a77..f8bfd2c 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainContract.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainContract.kt @@ -1,8 +1,6 @@ package com.motionapps.sensorbox.presentation.main import androidx.navigation3.runtime.NavKey -import com.motionapps.sensorbox.core.preferences.AppPreferences -import com.motionapps.sensorbox.domain.sensors.SensorDescriptor import com.motionapps.sensorservices.session.MeasurementSessionState import kotlinx.serialization.Serializable @@ -19,160 +17,9 @@ enum class MainRoute : NavKey { PRIVACY, } -enum class MainMessage { - NONE, - PICK_AT_LEAST_ONE_SOURCE, - STORAGE_REQUIRED, - PERMISSION_REQUIRED, - MEASUREMENT_FAILED, -} - data class MainState( val route: MainRoute = MainRoute.ONBOARDING, - val onboardingPage: Int = 0, - val sensors: List = emptyList(), - val wearSensors: List = emptyList(), - val detailsSensorType: Int? = null, - val selectedSensorIds: Set = emptySet(), - val includesGps: Boolean = false, - val selectedWearSensorIds: Set = emptySet(), - val wearIncludesGps: Boolean = false, - val customMeasurementName: String = "", - val measurementType: String = "ENDLESS", - val startDelaySeconds: Int = 0, - val durationSeconds: Int = 0, - val notes: String = "", - val alarmOffsets: String = "", - val activityRecognition: Boolean = false, - val activityRecognitionPeriodSeconds: Int = 30, - val significantMotion: Boolean = false, - val storagePath: String? = null, - val preferences: AppPreferences = AppPreferences(), - val hasLoadedPreferences: Boolean = false, val session: MeasurementSessionState = MeasurementSessionState.Idle, - val elapsedSeconds: Long = 0, - val isWearConnected: Boolean = false, - val message: MainMessage = MainMessage.NONE, + val keepScreenAwake: Boolean = false, + val hasLoadedPreferences: Boolean = false, ) - -sealed interface MainIntent { - data class Navigate(val route: MainRoute) : MainIntent - data class ToggleSensor(val sensorId: Int) : MainIntent - data class ToggleWearSensor(val sensorId: Int) : MainIntent - data class OpenSensorDetails(val sensorType: Int?) : MainIntent - data object ToggleGps : MainIntent - data object ToggleWearGps : MainIntent - data object AdvanceOnboarding : MainIntent - data object RetreatOnboarding : MainIntent - data object CompleteOnboarding : MainIntent - data object OpenPrivacyPolicy : MainIntent - data object OpenTermsOfUse : MainIntent - data object RequestBatteryOptimizationExemption : MainIntent - data object ShareDiagnosticsText : MainIntent - data object ShareDiagnosticsFile : MainIntent - data object ChooseStorage : MainIntent - data object OpenMeasurementSetup : MainIntent - data object ReturnToSensorSelection : MainIntent - data object StartMeasurement : MainIntent - data object StopMeasurement : MainIntent - data object ClearMessage : MainIntent - data class SetCustomMeasurementName(val value: String) : MainIntent - data class SetMeasurementType(val value: String) : MainIntent - data class SetStartDelay(val seconds: Int) : MainIntent - data class SetDuration(val seconds: Int) : MainIntent - data class SetNotes(val value: String) : MainIntent - data class SetAlarmOffsets(val value: String) : MainIntent - data class SetActivityRecognition(val enabled: Boolean) : MainIntent - data class SetActivityRecognitionPeriod(val seconds: Int) : MainIntent - data class SetSignificantMotion(val enabled: Boolean) : MainIntent - data class AddAnnotation(val text: String) : MainIntent - data class SetSamplingPeriod(val index: Int) : MainIntent - data class SetLowBatteryRestriction(val enabled: Boolean) : MainIntent - data class SetWakeLock(val enabled: Boolean) : MainIntent - data class SetKeepScreenAwake(val enabled: Boolean) : MainIntent - data class SetGpsInterval(val seconds: Int) : MainIntent - data class SetGpsDistance(val meters: Int) : MainIntent -} - -sealed interface MainEffect { - data object PickStorageDirectory : MainEffect - data object OpenPrivacyPolicy : MainEffect - data object OpenTermsOfUse : MainEffect - data object RequestBatteryOptimizationExemption : MainEffect - data object ShareDiagnosticsText : MainEffect - data object ShareDiagnosticsFile : MainEffect - data class RequestPermissions(val permissions: Set) : MainEffect -} - -data class MainNext(val state: MainState, val effect: MainEffect? = null) - -object MainReducer { - fun reduce(state: MainState, intent: MainIntent): MainNext = when (intent) { - MainIntent.AdvanceOnboarding -> MainNext(state.copy(onboardingPage = state.onboardingPage + 1)) - MainIntent.RetreatOnboarding -> MainNext(state.retreatOnboarding()) - is MainIntent.OpenSensorDetails -> MainNext(state.openSensorDetails(intent.sensorType)) - else -> reduceGeneralIntent(state, intent) - } - - private fun reduceGeneralIntent(state: MainState, intent: MainIntent): MainNext = when (intent) { - MainIntent.ChooseStorage -> MainNext(state, MainEffect.PickStorageDirectory) - MainIntent.ClearMessage -> MainNext(state.copy(message = MainMessage.NONE)) - MainIntent.OpenPrivacyPolicy -> MainNext(state, MainEffect.OpenPrivacyPolicy) - MainIntent.OpenTermsOfUse -> MainNext(state, MainEffect.OpenTermsOfUse) - MainIntent.RequestBatteryOptimizationExemption -> batteryOptimizationEffect(state) - MainIntent.ShareDiagnosticsText -> MainNext(state, MainEffect.ShareDiagnosticsText) - MainIntent.ShareDiagnosticsFile -> MainNext(state, MainEffect.ShareDiagnosticsFile) - MainIntent.OpenMeasurementSetup -> MainNext(state.openMeasurementSetup()) - MainIntent.ReturnToSensorSelection -> MainNext(state.returnToSensorSelection()) - else -> reduceSelectionIntent(state, intent) - } - - private fun reduceSelectionIntent(state: MainState, intent: MainIntent): MainNext = when (intent) { - MainIntent.ToggleGps -> MainNext(state.copy(includesGps = !state.includesGps)) - MainIntent.ToggleWearGps -> MainNext(state.copy(wearIncludesGps = !state.wearIncludesGps)) - is MainIntent.Navigate -> MainNext(state.copy(route = intent.route)) - is MainIntent.ToggleSensor -> MainNext(state.toggleSensor(intent.sensorId)) - is MainIntent.ToggleWearSensor -> MainNext(state.toggleWearSensor(intent.sensorId)) - else -> reduceConfigurationIntent(state, intent) - } - - private fun reduceConfigurationIntent(state: MainState, intent: MainIntent): MainNext = when (intent) { - is MainIntent.SetCustomMeasurementName -> MainNext(state.copy(customMeasurementName = intent.value)) - is MainIntent.SetMeasurementType -> MainNext(state.copy(measurementType = intent.value)) - is MainIntent.SetStartDelay -> MainNext(state.copy(startDelaySeconds = intent.seconds.coerceAtLeast(0))) - is MainIntent.SetDuration -> MainNext(state.copy(durationSeconds = intent.seconds.coerceAtLeast(0))) - is MainIntent.SetNotes -> MainNext(state.copy(notes = intent.value)) - is MainIntent.SetAlarmOffsets -> MainNext(state.copy(alarmOffsets = intent.value)) - is MainIntent.SetActivityRecognition -> MainNext(state.copy(activityRecognition = intent.enabled)) - is MainIntent.SetActivityRecognitionPeriod -> MainNext(state.withActivityPeriod(intent.seconds)) - is MainIntent.SetSignificantMotion -> MainNext(state.copy(significantMotion = intent.enabled)) - else -> MainNext(state) - } - - private fun batteryOptimizationEffect(state: MainState) = - MainNext(state, MainEffect.RequestBatteryOptimizationExemption) - - private fun MainState.retreatOnboarding() = copy(onboardingPage = (onboardingPage - 1).coerceAtLeast(0)) - - private fun MainState.openSensorDetails(sensorType: Int?) = - copy(route = MainRoute.SENSOR_DETAILS, detailsSensorType = sensorType) - - private fun MainState.openMeasurementSetup() = copy(route = MainRoute.SETUP, message = MainMessage.NONE) - - private fun MainState.returnToSensorSelection() = copy(route = MainRoute.RECORD, message = MainMessage.NONE) - - private fun MainState.toggleSensor(sensorId: Int): MainState { - val updated = selectedSensorIds.toMutableSet() - if (!updated.add(sensorId)) updated.remove(sensorId) - return copy(selectedSensorIds = updated, message = MainMessage.NONE) - } - - private fun MainState.toggleWearSensor(sensorId: Int): MainState { - val updated = selectedWearSensorIds.toMutableSet() - if (!updated.add(sensorId)) updated.remove(sensorId) - return copy(selectedWearSensorIds = updated, message = MainMessage.NONE) - } -} - -private fun MainState.withActivityPeriod(seconds: Int) = - copy(activityRecognitionPeriodSeconds = seconds.coerceAtLeast(1)) diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainViewModel.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainViewModel.kt index b92ce7c..6decf57 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainViewModel.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/MainViewModel.kt @@ -1,322 +1,72 @@ package com.motionapps.sensorbox.presentation.main -import android.content.Intent -import android.os.SystemClock import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.motionapps.sensorbox.core.error.AppError -import com.motionapps.sensorbox.core.error.suspendFlatMap -import com.motionapps.sensorbox.core.preferences.AppPreferencesIntent import com.motionapps.sensorbox.core.preferences.AppPreferencesRepository -import com.motionapps.sensorbox.domain.measurement.DocumentStorageUseCase -import com.motionapps.sensorbox.domain.measurement.MeasurementControlUseCase -import com.motionapps.sensorbox.domain.measurement.MeasurementPermissionUseCase -import com.motionapps.sensorbox.domain.measurement.MeasurementRequest -import com.motionapps.sensorbox.domain.sensors.GetAvailableSensorsUseCase -import com.motionapps.sensorbox.domain.sensors.SensorDescriptor -import com.motionapps.sensorservices.session.MeasurementSessionState import com.motionapps.sensorservices.session.MeasurementSessionStore -import com.motionapps.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY -import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH -import com.motionapps.wearoslib.connectivity.ObserveWearCapabilityUseCase -import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase -import com.motionapps.wearoslib.connectivity.WearConnection -import com.motionapps.wearoslib.protocol.WearCommand -import com.motionapps.wearoslib.protocol.WearCommandCodec -import com.motionapps.wearoslib.protocol.WearSensorCatalogStore import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -@Suppress("TooManyFunctions") class MainViewModel @Inject constructor( private val preferencesRepository: AppPreferencesRepository, - private val getAvailableSensors: GetAvailableSensorsUseCase, - private val documentStorage: DocumentStorageUseCase, - private val measurementPermissions: MeasurementPermissionUseCase, - private val measurementControl: MeasurementControlUseCase, private val sessionStore: MeasurementSessionStore, - private val observeWearCapability: ObserveWearCapabilityUseCase, - private val sendWearMessage: SendWearMessageUseCase, - private val wearSensorCatalog: WearSensorCatalogStore, ) : ViewModel() { - private val mutableState = MutableStateFlow(initialState()) - private val mutableEffects = Channel(Channel.BUFFERED) + private val mutableState = MutableStateFlow(MainState()) private var hasChosenInitialRoute = false - private var elapsedJob: Job? = null val state: StateFlow = mutableState.asStateFlow() - val effects = mutableEffects.receiveAsFlow() init { observePreferences() - observeMeasurementSession() - observeWearConnection() - observeWearSensors() + observeSession() } - fun accept(intent: MainIntent) { - when (intent) { - MainIntent.CompleteOnboarding -> completeOnboarding() - - MainIntent.StartMeasurement -> startMeasurement() - - MainIntent.StopMeasurement -> measurementControl.stop().showFailure() - - is MainIntent.AddAnnotation -> measurementControl.annotate(intent.text).showFailure() - - is MainIntent.SetSamplingPeriod -> updatePreference( - AppPreferencesIntent.SetSensorSamplingPeriod(intent.index), - ) - - is MainIntent.SetLowBatteryRestriction -> updatePreference( - AppPreferencesIntent.SetLowBatteryRestriction(intent.enabled), - ) - - is MainIntent.SetWakeLock -> updatePreference(AppPreferencesIntent.SetWakeLock(intent.enabled)) - - is MainIntent.SetKeepScreenAwake -> updatePreference( - AppPreferencesIntent.SetKeepPhoneDisplayOn(intent.enabled), - ) - - is MainIntent.SetGpsInterval -> updatePreference(AppPreferencesIntent.SetGpsInterval(intent.seconds)) - - is MainIntent.SetGpsDistance -> updatePreference(AppPreferencesIntent.SetGpsMinDistance(intent.meters)) - - else -> reduce(intent) - } - } - - fun handleStorageResult(resultIntent: Intent?) { - val persisted = resultIntent?.let(documentStorage::persist) ?: Result.failure( - com.motionapps.sensorbox.core.error.AppError( - com.motionapps.sensorbox.core.error.AppError.Kind.STORAGE, - "Select storage directory", - ), - ) - mutableState.value = state.value.copy( - storagePath = documentStorage.displayPath().getOrNull(), - message = if (persisted.isSuccess) MainMessage.NONE else MainMessage.STORAGE_REQUIRED, - ) - } - - fun handlePermissionResult() { - val request = state.value.toMeasurementRequest() - val missing = measurementPermissions.missingPermissions(request, state.value.includesHeartRate()) - if (missing.isEmpty()) startMeasurement() else showMessage(MainMessage.PERMISSION_REQUIRED) + fun navigate(route: MainRoute) { + hasChosenInitialRoute = true + mutableState.value = state.value.copy(route = route) } fun showPrivacyRationale() { - hasChosenInitialRoute = true - reduce(MainIntent.Navigate(MainRoute.PRIVACY)) + navigate(MainRoute.PRIVACY) } - private fun initialState() = MainState( - sensors = getAvailableSensors(), - storagePath = documentStorage.displayPath().getOrNull(), - ) - private fun observePreferences() { viewModelScope.launch { preferencesRepository.preferences.collect { result -> result.fold( onSuccess = { preferences -> - val route = initialRoute(preferences.hasCompletedIntro, preferences.hasAcceptedPolicy) + val route = if (hasChosenInitialRoute) { + state.value.route + } else if (preferences.hasCompletedIntro && preferences.hasAcceptedPolicy) { + MainRoute.RECORD + } else { + MainRoute.ONBOARDING + } + hasChosenInitialRoute = true mutableState.value = state.value.copy( - preferences = preferences, - hasLoadedPreferences = true, route = route, + keepScreenAwake = preferences.keepPhoneDisplayOn, + hasLoadedPreferences = true, ) }, onFailure = { - mutableState.value = state.value.copy( - hasLoadedPreferences = true, - message = MainMessage.MEASUREMENT_FAILED, - ) + mutableState.value = state.value.copy(hasLoadedPreferences = true) }, ) } } } - private fun initialRoute(completedIntro: Boolean, acceptedPolicy: Boolean): MainRoute { - if (hasChosenInitialRoute) return state.value.route - hasChosenInitialRoute = true - return if (completedIntro && acceptedPolicy) MainRoute.RECORD else MainRoute.ONBOARDING - } - - private fun observeMeasurementSession() { + private fun observeSession() { viewModelScope.launch { sessionStore.state.collect { session -> - onSessionChanged(session) - } - } - } - - private fun onSessionChanged(session: MeasurementSessionState) { - elapsedJob?.cancel() - mutableState.value = state.value.copy(session = session, elapsedSeconds = 0) - if (session is MeasurementSessionState.Running) startElapsedTicker(session.startedAtElapsedRealtime) - } - - private fun startElapsedTicker(startedAt: Long) { - elapsedJob = viewModelScope.launch { - while (isActive) { - val seconds = (SystemClock.elapsedRealtime() - startedAt).coerceAtLeast(0) / 1_000L - mutableState.value = state.value.copy(elapsedSeconds = seconds) - delay(1_000L) - } - } - } - - private fun observeWearConnection() { - viewModelScope.launch { - observeWearCapability(WEAR_APP_CAPABILITY) - .catch { error -> - AppError.from(AppError.Kind.CONNECTIVITY, "Observe Wear connection", error) - emit(WearConnection.Disconnected) - } - .collect { connection -> - mutableState.value = state.value.copy(isWearConnected = connection is WearConnection.Connected) - if (connection is WearConnection.Connected) { - WearCommandCodec.encode(WearCommand.RequestSensorList).suspendFlatMap { payload -> - sendWearMessage(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, payload) - } - } else { - wearSensorCatalog.clear() - } - } - } - } - - private fun observeWearSensors() { - viewModelScope.launch { - wearSensorCatalog.sensors.collect { sensors -> - mutableState.value = state.value.copy( - wearSensors = sensors.map { sensor -> - SensorDescriptor( - type = sensor.type, - name = sensor.name, - vendor = sensor.vendor, - isHeartRate = sensor.isHeartRate, - ) - }, - ) - } - } - } - - private fun reduce(intent: MainIntent) { - val next = MainReducer.reduce(state.value, intent) - mutableState.value = next.state - next.effect?.let(mutableEffects::trySend) - } - - private fun completeOnboarding() { - if (!documentStorage.hasStorage().getOrElse { - showMessage(MainMessage.STORAGE_REQUIRED) - return - } - ) { - showMessage(MainMessage.STORAGE_REQUIRED) - return - } - viewModelScope.launch { - preferencesRepository.dispatch(AppPreferencesIntent.AcceptPolicy).getOrElse { - showMessage(MainMessage.MEASUREMENT_FAILED) - return@launch - } - preferencesRepository.dispatch(AppPreferencesIntent.CompleteIntro).getOrElse { - showMessage(MainMessage.MEASUREMENT_FAILED) - return@launch + mutableState.value = state.value.copy(session = session) } - mutableState.value = state.value.copy(route = MainRoute.RECORD) } } - - @Suppress("ReturnCount") - private fun startMeasurement() { - val request = state.value.toMeasurementRequest() - if (!request.hasAnySource()) { - showMessage(MainMessage.PICK_AT_LEAST_ONE_SOURCE) - return - } - if (!documentStorage.hasStorage().getOrElse { - showMessage(MainMessage.STORAGE_REQUIRED) - return - } - ) { - reduce(MainIntent.ChooseStorage) - showMessage(MainMessage.STORAGE_REQUIRED) - return - } - requestMissingPermissions(request)?.let { - mutableEffects.trySend(MainEffect.RequestPermissions(it)) - return - } - startForegroundMeasurement(request) - } - - private fun requestMissingPermissions(request: MeasurementRequest): Set? { - val permissions = measurementPermissions.missingPermissions(request, state.value.includesHeartRate()) - return permissions.takeIf(Set::isNotEmpty) - } - - private fun startForegroundMeasurement(request: MeasurementRequest) { - viewModelScope.launch { - val result = measurementControl.start(request) - if (result.isFailure) showMessage(MainMessage.MEASUREMENT_FAILED) - } - } - - private fun updatePreference(intent: AppPreferencesIntent) { - viewModelScope.launch { preferencesRepository.dispatch(intent).showFailure() } - } - - private fun showMessage(message: MainMessage) { - mutableState.value = state.value.copy(message = message) - } - - private fun Result<*>.showFailure() { - if (isFailure) showMessage(MainMessage.MEASUREMENT_FAILED) - } - - private fun MainState.toMeasurementRequest() = MeasurementRequest( - sensorIds = selectedSensorIds, - includesGps = includesGps, - samplingPeriodIndex = preferences.sensorSamplingPeriod, - stopOnLowBattery = preferences.restrictMeasurementOnLowBattery, - useWakeLock = preferences.useWakeLock, - gpsIntervalSeconds = preferences.gpsIntervalSeconds, - gpsMinDistanceMeters = preferences.gpsMinDistanceMeters, - wearSensorIds = selectedWearSensorIds, - wearIncludesGps = wearIncludesGps, - customName = customMeasurementName, - measurementType = measurementType, - delaySeconds = startDelaySeconds, - durationSeconds = if (measurementType == "TIMED") durationSeconds.coerceAtLeast(1) else 0, - notes = notes.lines().map(String::trim).filter(String::isNotEmpty), - alarmOffsetsSeconds = alarmOffsets.split(',', ';', ' ') - .mapNotNull(String::toIntOrNull).filter { it >= 0 }, - activityRecognition = activityRecognition, - activityRecognitionPeriodSeconds = activityRecognitionPeriodSeconds, - significantMotion = significantMotion, - ) - - private fun MainState.includesHeartRate(): Boolean = sensors.any { - it.isHeartRate && it.type in selectedSensorIds - } - - private fun MeasurementRequest.hasAnySource(): Boolean = sensorIds.isNotEmpty() || includesGps || - wearSensorIds.isNotEmpty() || wearIncludesGps || activityRecognition || significantMotion } diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingContract.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingContract.kt new file mode 100644 index 0000000..d0cd807 --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingContract.kt @@ -0,0 +1,23 @@ +package com.motionapps.sensorbox.presentation.main + +import com.motionapps.sensorbox.core.error.AppErrorCode + +data class OnboardingState(val page: Int = 0, val storagePath: String? = null, val errorCode: AppErrorCode? = null) + +sealed interface OnboardingIntent { + data object AdvanceOnboarding : OnboardingIntent + data object RetreatOnboarding : OnboardingIntent + data object CompleteOnboarding : OnboardingIntent + data object OpenPrivacyPolicy : OnboardingIntent + data object OpenTermsOfUse : OnboardingIntent + data object RequestBatteryOptimizationExemption : OnboardingIntent + data object ChooseStorage : OnboardingIntent +} + +sealed interface OnboardingEffect { + data object PickStorageDirectory : OnboardingEffect + data object OpenPrivacyPolicy : OnboardingEffect + data object OpenTermsOfUse : OnboardingEffect + data object RequestBatteryOptimizationExemption : OnboardingEffect + data class Navigate(val route: MainRoute) : OnboardingEffect +} diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingViewModel.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingViewModel.kt new file mode 100644 index 0000000..1dbf2f0 --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/OnboardingViewModel.kt @@ -0,0 +1,86 @@ +package com.motionapps.sensorbox.presentation.main + +import android.content.Intent +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.preferences.AppPreferencesIntent +import com.motionapps.sensorbox.core.preferences.AppPreferencesRepository +import com.motionapps.sensorbox.domain.measurement.DocumentStorageGateway +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class OnboardingViewModel @Inject constructor( + private val preferencesRepository: AppPreferencesRepository, + private val documentStorage: DocumentStorageGateway, +) : ViewModel() { + private val mutableState = MutableStateFlow( + OnboardingState(storagePath = documentStorage.displayPath().getOrNull()), + ) + private val mutableEffects = Channel(Channel.BUFFERED) + + val state: StateFlow = mutableState.asStateFlow() + val effects = mutableEffects.receiveAsFlow() + + fun accept(intent: OnboardingIntent) { + when (intent) { + OnboardingIntent.AdvanceOnboarding -> mutableState.value = state.value.copy( + page = state.value.page + 1, + errorCode = null, + ) + + OnboardingIntent.RetreatOnboarding -> mutableState.value = state.value.copy( + page = (state.value.page - 1).coerceAtLeast(0), + errorCode = null, + ) + + OnboardingIntent.CompleteOnboarding -> completeOnboarding() + + OnboardingIntent.ChooseStorage -> mutableEffects.trySend(OnboardingEffect.PickStorageDirectory) + + OnboardingIntent.OpenPrivacyPolicy -> mutableEffects.trySend(OnboardingEffect.OpenPrivacyPolicy) + + OnboardingIntent.OpenTermsOfUse -> mutableEffects.trySend(OnboardingEffect.OpenTermsOfUse) + + OnboardingIntent.RequestBatteryOptimizationExemption -> + mutableEffects.trySend(OnboardingEffect.RequestBatteryOptimizationExemption) + } + } + + fun handleStorageResult(resultIntent: Intent?) { + val result = resultIntent?.let(documentStorage::persist) ?: AppResult.failure( + AppError(AppErrorCode.STORAGE, "Select onboarding storage directory"), + ) + mutableState.value = state.value.copy( + storagePath = documentStorage.displayPath().getOrNull(), + errorCode = result.errorOrNull()?.code, + ) + } + + private fun completeOnboarding() { + if (documentStorage.hasStorage().getOrNull() != true) { + 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) + 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) + } + } + } +} diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingContract.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingContract.kt new file mode 100644 index 0000000..932411a --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingContract.kt @@ -0,0 +1,155 @@ +package com.motionapps.sensorbox.presentation.main + +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.preferences.AppPreferences +import com.motionapps.sensorbox.domain.sensors.SensorDescriptor +import com.motionapps.sensorservices.session.MeasurementSessionState + +enum class RecordingMessage { + NONE, + PICK_AT_LEAST_ONE_SOURCE, + STORAGE_REQUIRED, + PERMISSION_REQUIRED, + MEASUREMENT_FAILED, +} + +data class RecordingState( + val sensors: List = emptyList(), + val wearSensors: List = emptyList(), + val detailsSensorType: Int? = null, + val selectedSensorIds: Set = emptySet(), + val includesGps: Boolean = false, + val selectedWearSensorIds: Set = emptySet(), + val wearIncludesGps: Boolean = false, + val customMeasurementName: String = "", + val measurementType: String = "ENDLESS", + val startDelaySeconds: Int = 0, + val durationSeconds: Int = 0, + val notes: String = "", + val alarmOffsets: String = "", + val activityRecognition: Boolean = false, + val activityRecognitionPeriodSeconds: Int = 30, + val significantMotion: Boolean = false, + val storagePath: String? = null, + val preferences: AppPreferences = AppPreferences(), + val session: MeasurementSessionState = MeasurementSessionState.Idle, + val elapsedSeconds: Long = 0, + val isWearConnected: Boolean = false, + val message: RecordingMessage = RecordingMessage.NONE, + val errorCode: AppErrorCode? = null, +) + +sealed interface RecordingIntent { + data class Navigate(val route: MainRoute) : RecordingIntent + data class ToggleSensor(val sensorId: Int) : RecordingIntent + data class ToggleWearSensor(val sensorId: Int) : RecordingIntent + data class OpenSensorDetails(val sensorType: Int?) : RecordingIntent + data object ToggleGps : RecordingIntent + data object ToggleWearGps : RecordingIntent + data object ChooseStorage : RecordingIntent + data object OpenMeasurementSetup : RecordingIntent + data object ReturnToSensorSelection : RecordingIntent + data object StartMeasurement : RecordingIntent + data object StopMeasurement : RecordingIntent + data object ClearMessage : RecordingIntent + data class SetCustomMeasurementName(val value: String) : RecordingIntent + data class SetMeasurementType(val value: String) : RecordingIntent + data class SetStartDelay(val seconds: Int) : RecordingIntent + data class SetDuration(val seconds: Int) : RecordingIntent + data class SetNotes(val value: String) : RecordingIntent + data class SetAlarmOffsets(val value: String) : RecordingIntent + data class SetActivityRecognition(val enabled: Boolean) : RecordingIntent + data class SetActivityRecognitionPeriod(val seconds: Int) : RecordingIntent + data class SetSignificantMotion(val enabled: Boolean) : RecordingIntent + data class AddAnnotation(val text: String) : RecordingIntent + data class SetSamplingPeriod(val index: Int) : RecordingIntent + data class SetLowBatteryRestriction(val enabled: Boolean) : RecordingIntent + data class SetWakeLock(val enabled: Boolean) : RecordingIntent + data class SetKeepScreenAwake(val enabled: Boolean) : RecordingIntent + data class SetGpsInterval(val seconds: Int) : RecordingIntent + data class SetGpsDistance(val meters: Int) : RecordingIntent +} + +sealed interface RecordingEffect { + data object PickStorageDirectory : RecordingEffect + data class RequestPermissions(val permissions: Set) : RecordingEffect + data class Navigate(val route: MainRoute) : RecordingEffect +} + +data class RecordingNext(val state: RecordingState, val effect: RecordingEffect? = null) + +object RecordingReducer { + fun reduce(state: RecordingState, intent: RecordingIntent): RecordingNext = reduceNavigation(state, intent) + ?: reduceSelection(state, intent) + ?: reduceConfiguration(state, intent) + + private fun reduceNavigation(state: RecordingState, intent: RecordingIntent): RecordingNext? = when (intent) { + RecordingIntent.ChooseStorage -> RecordingNext(state, RecordingEffect.PickStorageDirectory) + + is RecordingIntent.Navigate -> RecordingNext(state, RecordingEffect.Navigate(intent.route)) + + is RecordingIntent.OpenSensorDetails -> RecordingNext( + state.copy(detailsSensorType = intent.sensorType), + RecordingEffect.Navigate(MainRoute.SENSOR_DETAILS), + ) + + RecordingIntent.OpenMeasurementSetup -> RecordingNext( + state.copy(message = RecordingMessage.NONE), + RecordingEffect.Navigate(MainRoute.SETUP), + ) + + RecordingIntent.ReturnToSensorSelection -> RecordingNext( + state.copy(message = RecordingMessage.NONE), + RecordingEffect.Navigate(MainRoute.RECORD), + ) + + else -> null + } + + private fun reduceSelection(state: RecordingState, intent: RecordingIntent): RecordingNext? = when (intent) { + RecordingIntent.ClearMessage -> RecordingNext(state.copy(message = RecordingMessage.NONE, errorCode = null)) + RecordingIntent.ToggleGps -> RecordingNext(state.copy(includesGps = !state.includesGps)) + RecordingIntent.ToggleWearGps -> RecordingNext(state.copy(wearIncludesGps = !state.wearIncludesGps)) + is RecordingIntent.ToggleSensor -> RecordingNext(state.toggleSensor(intent.sensorId)) + is RecordingIntent.ToggleWearSensor -> RecordingNext(state.toggleWearSensor(intent.sensorId)) + else -> null + } + + private fun reduceConfiguration(state: RecordingState, intent: RecordingIntent): RecordingNext = when (intent) { + is RecordingIntent.SetCustomMeasurementName -> RecordingNext(state.copy(customMeasurementName = intent.value)) + + is RecordingIntent.SetMeasurementType -> RecordingNext(state.copy(measurementType = intent.value)) + + is RecordingIntent.SetStartDelay -> RecordingNext( + state.copy(startDelaySeconds = intent.seconds.coerceAtLeast(0)), + ) + + is RecordingIntent.SetDuration -> RecordingNext(state.copy(durationSeconds = intent.seconds.coerceAtLeast(0))) + + is RecordingIntent.SetNotes -> RecordingNext(state.copy(notes = intent.value)) + + is RecordingIntent.SetAlarmOffsets -> RecordingNext(state.copy(alarmOffsets = intent.value)) + + is RecordingIntent.SetActivityRecognition -> RecordingNext(state.copy(activityRecognition = intent.enabled)) + + is RecordingIntent.SetActivityRecognitionPeriod -> RecordingNext( + state.copy(activityRecognitionPeriodSeconds = intent.seconds.coerceAtLeast(1)), + ) + + is RecordingIntent.SetSignificantMotion -> RecordingNext(state.copy(significantMotion = intent.enabled)) + + else -> RecordingNext(state) + } + + private fun RecordingState.toggleSensor(sensorId: Int): RecordingState { + val updated = selectedSensorIds.toMutableSet() + if (!updated.add(sensorId)) updated.remove(sensorId) + return copy(selectedSensorIds = updated, message = RecordingMessage.NONE, errorCode = null) + } + + private fun RecordingState.toggleWearSensor(sensorId: Int): RecordingState { + val updated = selectedWearSensorIds.toMutableSet() + if (!updated.add(sensorId)) updated.remove(sensorId) + return copy(selectedWearSensorIds = updated, message = RecordingMessage.NONE, errorCode = null) + } +} diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt new file mode 100644 index 0000000..c2ac16a --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/RecordingViewModel.kt @@ -0,0 +1,288 @@ +package com.motionapps.sensorbox.presentation.main + +import android.content.Intent +import android.os.SystemClock +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.motionapps.sensorbox.core.error.AppError +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.suspendFlatMap +import com.motionapps.sensorbox.core.preferences.AppPreferencesIntent +import com.motionapps.sensorbox.core.preferences.AppPreferencesRepository +import com.motionapps.sensorbox.domain.measurement.MeasurementRequest +import com.motionapps.sensorbox.domain.measurement.RecordingWorkflowGateway +import com.motionapps.sensorbox.domain.sensors.SensorDescriptor +import com.motionapps.sensorbox.domain.sensors.WearSensorCatalogStore +import com.motionapps.sensorservices.session.MeasurementSessionState +import com.motionapps.sensorservices.session.MeasurementSessionStore +import com.motionapps.wearoslib.WearOsConstants.WEAR_APP_CAPABILITY +import com.motionapps.wearoslib.WearOsConstants.WEAR_MESSAGE_PATH +import com.motionapps.wearoslib.connectivity.ObserveWearCapabilityUseCase +import com.motionapps.wearoslib.connectivity.SendWearMessageUseCase +import com.motionapps.wearoslib.connectivity.WearConnection +import com.motionapps.wearoslib.protocol.WearCommand +import com.motionapps.wearoslib.protocol.WearCommandCodec +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +@Suppress("TooManyFunctions") +class RecordingViewModel @Inject constructor( + private val preferencesRepository: AppPreferencesRepository, + private val workflow: RecordingWorkflowGateway, + private val sessionStore: MeasurementSessionStore, + private val observeWearCapability: ObserveWearCapabilityUseCase, + private val sendWearMessage: SendWearMessageUseCase, + private val wearSensorCatalog: WearSensorCatalogStore, +) : ViewModel() { + private val mutableState = MutableStateFlow(initialState()) + private val mutableEffects = Channel(Channel.BUFFERED) + private var elapsedJob: Job? = null + + val state: StateFlow = mutableState.asStateFlow() + val effects = mutableEffects.receiveAsFlow() + + init { + observePreferences() + observeMeasurementSession() + observeWearConnection() + observeWearSensors() + } + + fun accept(intent: RecordingIntent) { + when (intent) { + RecordingIntent.StartMeasurement -> startMeasurement() + + RecordingIntent.StopMeasurement -> stopMeasurement() + + is RecordingIntent.AddAnnotation -> workflow.annotate(intent.text).showFailure() + + is RecordingIntent.SetSamplingPeriod -> updatePreference( + AppPreferencesIntent.SetSensorSamplingPeriod(intent.index), + ) + + is RecordingIntent.SetLowBatteryRestriction -> updatePreference( + AppPreferencesIntent.SetLowBatteryRestriction(intent.enabled), + ) + + is RecordingIntent.SetWakeLock -> updatePreference(AppPreferencesIntent.SetWakeLock(intent.enabled)) + + is RecordingIntent.SetKeepScreenAwake -> updatePreference( + AppPreferencesIntent.SetKeepPhoneDisplayOn(intent.enabled), + ) + + is RecordingIntent.SetGpsInterval -> updatePreference(AppPreferencesIntent.SetGpsInterval(intent.seconds)) + + is RecordingIntent.SetGpsDistance -> updatePreference(AppPreferencesIntent.SetGpsMinDistance(intent.meters)) + + else -> reduce(intent) + } + } + + fun handleStorageResult(resultIntent: Intent?) { + val persisted = workflow.persistStorage(resultIntent) + mutableState.value = state.value.copy( + storagePath = workflow.storagePath(), + message = if (persisted.isSuccess) RecordingMessage.NONE else RecordingMessage.STORAGE_REQUIRED, + errorCode = persisted.errorOrNull()?.code, + ) + } + + fun handlePermissionResult() { + val request = state.value.toMeasurementRequest() + val missing = workflow.missingPermissions(request, state.value.includesHeartRate()) + if (missing.isEmpty()) { + startMeasurement() + } else { + showMessage(RecordingMessage.PERMISSION_REQUIRED, AppErrorCode.PERMISSION) + } + } + + private fun initialState() = RecordingState( + sensors = workflow.sensors(), + storagePath = workflow.storagePath(), + ) + + private fun observePreferences() { + viewModelScope.launch { + preferencesRepository.preferences.collect { result -> + result.fold( + onSuccess = { preferences -> + mutableState.value = state.value.copy( + preferences = preferences, + errorCode = null, + ) + }, + onFailure = { error -> + mutableState.value = state.value.copy( + message = RecordingMessage.MEASUREMENT_FAILED, + errorCode = error.code, + ) + }, + ) + } + } + } + + private fun observeMeasurementSession() { + viewModelScope.launch { + sessionStore.state.collect { session -> + onSessionChanged(session) + } + } + } + + private fun onSessionChanged(session: MeasurementSessionState) { + elapsedJob?.cancel() + mutableState.value = state.value.copy(session = session, elapsedSeconds = 0) + if (session is MeasurementSessionState.Running) startElapsedTicker(session.startedAtElapsedRealtime) + } + + private fun startElapsedTicker(startedAt: Long) { + elapsedJob = viewModelScope.launch { + while (isActive) { + val seconds = (SystemClock.elapsedRealtime() - startedAt).coerceAtLeast(0) / 1_000L + mutableState.value = state.value.copy(elapsedSeconds = seconds) + delay(1_000L) + } + } + } + + private fun observeWearConnection() { + viewModelScope.launch { + observeWearCapability(WEAR_APP_CAPABILITY) + .catch { error -> + AppError.from(AppErrorCode.CONNECTIVITY, "Observe Wear connection", error) + emit(WearConnection.Disconnected) + } + .collect { connection -> + mutableState.value = state.value.copy(isWearConnected = connection is WearConnection.Connected) + if (connection is WearConnection.Connected) { + WearCommandCodec.encode(WearCommand.RequestSensorList).suspendFlatMap { payload -> + sendWearMessage(WEAR_APP_CAPABILITY, WEAR_MESSAGE_PATH, payload) + } + } else { + wearSensorCatalog.clear() + } + } + } + } + + private fun observeWearSensors() { + viewModelScope.launch { + wearSensorCatalog.sensors.collect { sensors -> + mutableState.value = state.value.copy( + wearSensors = sensors.map { sensor -> + SensorDescriptor( + type = sensor.type, + name = sensor.name, + vendor = sensor.vendor, + isHeartRate = sensor.isHeartRate, + ) + }, + ) + } + } + } + + private fun reduce(intent: RecordingIntent) { + val next = RecordingReducer.reduce(state.value, intent) + mutableState.value = next.state + next.effect?.let(mutableEffects::trySend) + } + + @Suppress("ReturnCount") + private fun startMeasurement() { + val request = state.value.toMeasurementRequest() + if (!request.hasAnySource()) { + showMessage(RecordingMessage.PICK_AT_LEAST_ONE_SOURCE, AppErrorCode.VALIDATION) + return + } + if (!workflow.hasStorage()) { + reduce(RecordingIntent.ChooseStorage) + showMessage(RecordingMessage.STORAGE_REQUIRED, AppErrorCode.STORAGE) + return + } + requestMissingPermissions(request)?.let { + mutableEffects.trySend(RecordingEffect.RequestPermissions(it)) + mutableState.value = state.value.copy(errorCode = AppErrorCode.PERMISSION) + return + } + startForegroundMeasurement(request) + } + + private fun requestMissingPermissions(request: MeasurementRequest): Set? { + val permissions = workflow.missingPermissions(request, state.value.includesHeartRate()) + return permissions.takeIf(Set::isNotEmpty) + } + + private fun startForegroundMeasurement(request: MeasurementRequest) { + viewModelScope.launch { + val result = workflow.start(request) + if (result.isFailure) showFailure(result.errorOrNull()) + } + } + + private fun stopMeasurement() { + viewModelScope.launch { workflow.stop().showFailure() } + } + + private fun updatePreference(intent: AppPreferencesIntent) { + viewModelScope.launch { preferencesRepository.dispatch(intent).showFailure() } + } + + private fun showMessage(message: RecordingMessage, errorCode: AppErrorCode? = null) { + mutableState.value = state.value.copy(message = message, errorCode = errorCode) + } + + private fun AppResult<*>.showFailure() { + if (isFailure) showFailure(errorOrNull()) + } + + private fun showFailure(error: com.motionapps.sensorbox.core.error.AppError?) { + mutableState.value = state.value.copy( + message = RecordingMessage.MEASUREMENT_FAILED, + errorCode = error?.code ?: AppErrorCode.UNKNOWN, + ) + } + + private fun RecordingState.toMeasurementRequest() = MeasurementRequest( + sensorIds = selectedSensorIds, + includesGps = includesGps, + samplingPeriodIndex = preferences.sensorSamplingPeriod, + stopOnLowBattery = preferences.restrictMeasurementOnLowBattery, + useWakeLock = preferences.useWakeLock, + gpsIntervalSeconds = preferences.gpsIntervalSeconds, + gpsMinDistanceMeters = preferences.gpsMinDistanceMeters, + wearSensorIds = selectedWearSensorIds, + wearIncludesGps = wearIncludesGps, + customName = customMeasurementName, + measurementType = measurementType, + delaySeconds = startDelaySeconds, + durationSeconds = if (measurementType == "TIMED") durationSeconds.coerceAtLeast(1) else 0, + notes = notes.lines().map(String::trim).filter(String::isNotEmpty), + alarmOffsetsSeconds = alarmOffsets.split(',', ';', ' ') + .mapNotNull(String::toIntOrNull).filter { it >= 0 }, + activityRecognition = activityRecognition, + activityRecognitionPeriodSeconds = activityRecognitionPeriodSeconds, + significantMotion = significantMotion, + ) + + private fun RecordingState.includesHeartRate(): Boolean = sensors.any { + it.isHeartRate && it.type in selectedSensorIds + } + + private fun MeasurementRequest.hasAnySource(): Boolean = sensorIds.isNotEmpty() || includesGps || + wearSensorIds.isNotEmpty() || wearIncludesGps || activityRecognition || significantMotion +} diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorBoxApp.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorBoxApp.kt index 6a19ac6..2b96ee7 100644 --- a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorBoxApp.kt +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SensorBoxApp.kt @@ -34,23 +34,48 @@ import androidx.navigation3.ui.NavDisplay import com.motionapps.sensorservices.session.MeasurementSessionState @Composable -fun SensorBoxApp(state: MainState, onIntent: (MainIntent) -> Unit) { - if (!state.hasLoadedPreferences) { +fun SensorBoxApp( + mainState: MainState, + onboardingState: OnboardingState, + recordingState: RecordingState, + settingsState: SettingsState, + onNavigate: (MainRoute) -> Unit, + onOnboardingIntent: (OnboardingIntent) -> Unit, + onRecordingIntent: (RecordingIntent) -> Unit, + onSettingsIntent: (SettingsIntent) -> Unit, +) { + if (!mainState.hasLoadedPreferences) { Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) return } - if (state.preferences.keepPhoneDisplayOn && state.session is MeasurementSessionState.Running) { - KeepScreenAwake() - } - SensorBoxNavHost(state, onIntent) + if (mainState.keepScreenAwake && mainState.session is MeasurementSessionState.Running) KeepScreenAwake() + SensorBoxNavHost( + mainState = mainState, + onboardingState = onboardingState, + recordingState = recordingState, + settingsState = settingsState, + onNavigate = onNavigate, + onOnboardingIntent = onOnboardingIntent, + onRecordingIntent = onRecordingIntent, + onSettingsIntent = onSettingsIntent, + ) } @Composable -private fun SensorBoxNavHost(state: MainState, onIntent: (MainIntent) -> Unit) { - val backStack = rememberNavBackStack(state.route) - val displayedRoute = state.displayedRoute() +private fun SensorBoxNavHost( + mainState: MainState, + onboardingState: OnboardingState, + recordingState: RecordingState, + settingsState: SettingsState, + onNavigate: (MainRoute) -> Unit, + onOnboardingIntent: (OnboardingIntent) -> Unit, + onRecordingIntent: (RecordingIntent) -> Unit, + onSettingsIntent: (SettingsIntent) -> Unit, +) { + val backStack = rememberNavBackStack(mainState.route) + val displayedRoute = mainState.displayedRoute() SynchronizeBackStack(backStack, displayedRoute) - val navigateBack = { navigateBack(state, backStack, onIntent) } + val navigateBack = { navigateBack(mainState, backStack, onNavigate) } NavDisplay( backStack = backStack, @@ -62,8 +87,12 @@ private fun SensorBoxNavHost(state: MainState, onIntent: (MainIntent) -> Unit) { entry { route -> RouteContent( route = route, - state = state, - onIntent = onIntent, + onboardingState = onboardingState, + recordingState = recordingState, + settingsState = settingsState, + onOnboardingIntent = onOnboardingIntent, + onRecordingIntent = onRecordingIntent, + onSettingsIntent = onSettingsIntent, onBack = navigateBack, ) } @@ -80,10 +109,7 @@ private fun MainState.displayedRoute() = if (session is MeasurementSessionState. @Composable private fun SynchronizeBackStack(backStack: NavBackStack, displayedRoute: MainRoute) { LaunchedEffect(displayedRoute) { - if ( - displayedRoute != MainRoute.ACTIVE_MEASUREMENT && - backStack.lastOrNull() == MainRoute.ACTIVE_MEASUREMENT - ) { + if (displayedRoute != MainRoute.ACTIVE_MEASUREMENT && backStack.lastOrNull() == MainRoute.ACTIVE_MEASUREMENT) { backStack.removeLastOrNull() } if (backStack.lastOrNull() != displayedRoute) { @@ -93,11 +119,11 @@ private fun SynchronizeBackStack(backStack: NavBackStack, displayedRoute } } -private fun navigateBack(state: MainState, backStack: NavBackStack, onIntent: (MainIntent) -> Unit) { +private fun navigateBack(state: MainState, backStack: NavBackStack, onNavigate: (MainRoute) -> Unit) { if (state.session is MeasurementSessionState.Running) return if (backStack.size > 1) backStack.removeLastOrNull() val destination = backStack.lastOrNull() as? MainRoute ?: MainRoute.RECORD - if (destination != MainRoute.ACTIVE_MEASUREMENT) onIntent(MainIntent.Navigate(destination)) + if (destination != MainRoute.ACTIVE_MEASUREMENT) onNavigate(destination) } private fun MainRoute.isRootDestination() = when (this) { @@ -116,31 +142,42 @@ private fun MainRoute.isRootDestination() = when (this) { } @Composable -private fun RouteContent(route: MainRoute, state: MainState, onIntent: (MainIntent) -> Unit, onBack: () -> Unit) { +private fun RouteContent( + route: MainRoute, + onboardingState: OnboardingState, + recordingState: RecordingState, + settingsState: SettingsState, + onOnboardingIntent: (OnboardingIntent) -> Unit, + onRecordingIntent: (RecordingIntent) -> Unit, + onSettingsIntent: (SettingsIntent) -> Unit, + onBack: () -> Unit, +) { when (route) { - MainRoute.ONBOARDING -> OnboardingScreen(state, onIntent) + MainRoute.ONBOARDING -> OnboardingScreen(onboardingState, onOnboardingIntent) MainRoute.ACTIVE_MEASUREMENT -> FullScreen { modifier -> - ActiveMeasurementScreen(state, onIntent, modifier) + ActiveMeasurementScreen(recordingState, onRecordingIntent, modifier) } MainRoute.SENSOR_DETAILS -> FullScreen { modifier -> - SensorDetailsScreen(state, onBack, { onIntent(MainIntent.Navigate(MainRoute.SENSOR_PREVIEW)) }, modifier) + SensorDetailsScreen( + recordingState, + onBack, + { onRecordingIntent(RecordingIntent.Navigate(MainRoute.SENSOR_PREVIEW)) }, + modifier, + ) } MainRoute.SENSOR_PREVIEW -> FullScreen { modifier -> - SensorPreviewScreen(state = state, onBack = onBack, modifier = modifier) + SensorPreviewScreen(state = recordingState, onBack = onBack, modifier = modifier) } MainRoute.SETUP -> FullScreen { modifier -> MeasurementSetupScreen( - state = state, - onIntent = onIntent, + state = recordingState, + onIntent = onRecordingIntent, modifier = modifier, - onBack = { - onIntent(MainIntent.ReturnToSensorSelection) - onBack() - }, + onBack = { onRecordingIntent(RecordingIntent.ReturnToSensorSelection) }, ) } @@ -148,10 +185,17 @@ private fun RouteContent(route: MainRoute, state: MainState, onIntent: (MainInte OpenSourceLicensesScreen(onBack = onBack, modifier = modifier) } - MainRoute.RECORD -> FullScreen { modifier -> RecordScreen(state, onIntent, modifier) } + MainRoute.RECORD -> FullScreen { modifier -> + RecordScreen(recordingState, onRecordingIntent, modifier) + } MainRoute.SETTINGS -> FullScreen { modifier -> - SettingsScreen(state = state, onIntent = onIntent, modifier = modifier, onBack = onBack) + SettingsScreen( + state = settingsState, + onIntent = onSettingsIntent, + modifier = modifier, + onBack = onBack, + ) } MainRoute.PRIVACY -> FullScreen { modifier -> PrivacyScreen(onBack, modifier) } @@ -179,11 +223,6 @@ private fun screenFadeInTween() = tween(SCREEN_FADE_IN_MILLIS, easing = F private fun screenFadeOutTween() = tween(SCREEN_FADE_OUT_MILLIS, easing = FastOutSlowInEasing) -private const val SCREEN_TRANSITION_MILLIS = 300 -private const val SCREEN_FADE_IN_MILLIS = 240 -private const val SCREEN_FADE_OUT_MILLIS = 180 -private const val SCREEN_SLIDE_DIVISOR = 5 - @Composable private fun KeepScreenAwake() { val view = LocalView.current @@ -202,13 +241,14 @@ private fun FullScreen(content: @Composable (Modifier) -> Unit) { contentAlignment = Alignment.TopCenter, ) { content( - Modifier - .widthIn(max = ADAPTIVE_CONTENT_MAX_WIDTH) - .fillMaxWidth() - .fillMaxHeight(), + Modifier.widthIn(max = ADAPTIVE_CONTENT_MAX_WIDTH).fillMaxWidth().fillMaxHeight(), ) } } } +private const val SCREEN_TRANSITION_MILLIS = 300 +private const val SCREEN_FADE_IN_MILLIS = 240 +private const val SCREEN_FADE_OUT_MILLIS = 180 +private const val SCREEN_SLIDE_DIVISOR = 5 private val ADAPTIVE_CONTENT_MAX_WIDTH = 840.dp diff --git a/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsContract.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsContract.kt new file mode 100644 index 0000000..ab6521a --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsContract.kt @@ -0,0 +1,37 @@ +package com.motionapps.sensorbox.presentation.main + +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.preferences.AppPreferences + +data class SettingsState( + val preferences: AppPreferences = AppPreferences(), + val diagnosticsText: String? = null, + val errorCode: AppErrorCode? = null, +) + +sealed interface SettingsIntent { + data class SetSamplingPeriod(val index: Int) : SettingsIntent + data class SetLowBatteryRestriction(val enabled: Boolean) : SettingsIntent + data class SetWakeLock(val enabled: Boolean) : SettingsIntent + data class SetKeepScreenAwake(val enabled: Boolean) : SettingsIntent + data class SetGpsInterval(val seconds: Int) : SettingsIntent + data class SetGpsDistance(val meters: Int) : SettingsIntent + data object RequestBatteryOptimizationExemption : SettingsIntent + data object ShareDiagnosticsText : SettingsIntent + data object ShareDiagnosticsFile : SettingsIntent + data object ViewDiagnostics : SettingsIntent + data object CopyDiagnostics : SettingsIntent + data object ClearDiagnostics : SettingsIntent + data object DismissDiagnostics : 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 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/motionapps/sensorbox/presentation/main/SettingsViewModel.kt b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsViewModel.kt new file mode 100644 index 0000000..c328990 --- /dev/null +++ b/app/src/main/java/com/motionapps/sensorbox/presentation/main/SettingsViewModel.kt @@ -0,0 +1,138 @@ +package com.motionapps.sensorbox.presentation.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.motionapps.sensorbox.core.error.AppErrorCode +import com.motionapps.sensorbox.core.error.AppResult +import com.motionapps.sensorbox.core.error.DiagnosticsStore +import com.motionapps.sensorbox.core.preferences.AppPreferencesIntent +import com.motionapps.sensorbox.core.preferences.AppPreferencesRepository +import com.motionapps.sensorbox.di.IoDispatcher +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val preferencesRepository: AppPreferencesRepository, + private val diagnosticsStore: DiagnosticsStore, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, +) : ViewModel() { + private val mutableState = MutableStateFlow(SettingsState()) + private val mutableEffects = Channel(Channel.BUFFERED) + + val state: StateFlow = mutableState.asStateFlow() + val effects = mutableEffects.receiveAsFlow() + + init { + 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) + } + } + } + } + + fun accept(intent: SettingsIntent) { + intent.toPreferencesIntent()?.let { + update(it) + return + } + when (intent) { + SettingsIntent.RequestBatteryOptimizationExemption -> + mutableEffects.trySend(SettingsEffect.RequestBatteryOptimizationExemption) + + SettingsIntent.ShareDiagnosticsText -> mutableEffects.trySend(SettingsEffect.ShareDiagnosticsText) + + SettingsIntent.ShareDiagnosticsFile -> mutableEffects.trySend(SettingsEffect.ShareDiagnosticsFile) + + SettingsIntent.ViewDiagnostics -> viewDiagnostics() + + SettingsIntent.CopyDiagnostics -> copyDiagnostics() + + SettingsIntent.ClearDiagnostics -> clearDiagnostics() + + SettingsIntent.DismissDiagnostics -> mutableState.value = state.value.copy(diagnosticsText = null) + + is SettingsIntent.Navigate -> mutableEffects.trySend(SettingsEffect.Navigate(intent.route)) + + is SettingsIntent.SetSamplingPeriod, + is SettingsIntent.SetLowBatteryRestriction, + is SettingsIntent.SetWakeLock, + is SettingsIntent.SetKeepScreenAwake, + is SettingsIntent.SetGpsInterval, + is SettingsIntent.SetGpsDistance, + -> Unit + } + } + + private fun SettingsIntent.toPreferencesIntent(): AppPreferencesIntent? = when (this) { + is SettingsIntent.SetSamplingPeriod -> AppPreferencesIntent.SetSensorSamplingPeriod(index) + is SettingsIntent.SetLowBatteryRestriction -> AppPreferencesIntent.SetLowBatteryRestriction(enabled) + is SettingsIntent.SetWakeLock -> AppPreferencesIntent.SetWakeLock(enabled) + is SettingsIntent.SetKeepScreenAwake -> AppPreferencesIntent.SetKeepPhoneDisplayOn(enabled) + is SettingsIntent.SetGpsInterval -> AppPreferencesIntent.SetGpsInterval(seconds) + is SettingsIntent.SetGpsDistance -> AppPreferencesIntent.SetGpsMinDistance(meters) + else -> null + } + + private fun update(intent: AppPreferencesIntent) { + viewModelScope.launch { + preferencesRepository.dispatch(intent).onFailure { error -> + mutableState.value = state.value.copy(errorCode = error.code) + } + } + } + + private fun viewDiagnostics() { + viewModelScope.launch(ioDispatcher) { + when (val result = diagnosticsStore.readText()) { + is AppResult.Success -> mutableState.value = state.value.copy( + diagnosticsText = result.value, + errorCode = null, + ) + + is AppResult.Failure -> fail(result.error.code) + } + } + } + + private fun copyDiagnostics() { + viewModelScope.launch(ioDispatcher) { + when (val result = diagnosticsStore.readText()) { + is AppResult.Success -> mutableEffects.send(SettingsEffect.CopyDiagnosticsText(result.value)) + is AppResult.Failure -> fail(result.error.code) + } + } + } + + private fun clearDiagnostics() { + viewModelScope.launch(ioDispatcher) { + when (val result = diagnosticsStore.clear()) { + is AppResult.Success -> { + mutableState.value = state.value.copy(diagnosticsText = null, errorCode = null) + mutableEffects.send(SettingsEffect.DiagnosticsCleared) + } + + is AppResult.Failure -> fail(result.error.code) + } + } + } + + private suspend fun fail(code: AppErrorCode) { + mutableState.value = state.value.copy(errorCode = code) + mutableEffects.send(SettingsEffect.DiagnosticsFailed(code)) + } +}