diff --git a/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/components/TimePicker.kt b/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/components/TimePicker.kt index d7b506e82..4a24d3872 100644 --- a/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/components/TimePicker.kt +++ b/app/src/main/kotlin/edu/stanford/bdh/engagehf/health/components/TimePicker.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import edu.stanford.bdh.engagehf.R +import edu.stanford.spezi.core.design.component.DatePickerDialog import edu.stanford.spezi.core.design.theme.Spacings import edu.stanford.spezi.core.design.theme.TextStyles import java.time.Instant @@ -53,7 +54,7 @@ fun TimePicker( } if (showDatePicker) { - edu.stanford.spezi.module.account.register.DatePickerDialog( + DatePickerDialog( onDateSelected = { date -> updateDate(date) }, diff --git a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/DatePickerDialog.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/DatePickerDialog.kt similarity index 86% rename from modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/DatePickerDialog.kt rename to core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/DatePickerDialog.kt index b79ff99cf..6fa7e1cc8 100644 --- a/modules/account/src/main/kotlin/edu/stanford/spezi/module/account/register/DatePickerDialog.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/DatePickerDialog.kt @@ -1,4 +1,4 @@ -package edu.stanford.spezi.module.account.register +package edu.stanford.spezi.core.design.component import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.Button @@ -11,18 +11,19 @@ import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import edu.stanford.spezi.module.account.R +import edu.stanford.spezi.core.design.R import java.time.Instant @OptIn(ExperimentalMaterial3Api::class) @Composable fun DatePickerDialog( onDateSelected: (Instant) -> Unit, + selectableDatesPredicate: (Instant) -> Boolean = { it <= Instant.now() }, onDismiss: () -> Unit, ) { val datePickerState = rememberDatePickerState(selectableDates = object : SelectableDates { override fun isSelectableDate(utcTimeMillis: Long): Boolean { - return utcTimeMillis <= System.currentTimeMillis() + return selectableDatesPredicate(Instant.ofEpochMilli(utcTimeMillis)) } }) diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/FocusEvent.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/FocusEvent.kt new file mode 100644 index 000000000..f77bd2d79 --- /dev/null +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/component/FocusEvent.kt @@ -0,0 +1,23 @@ +package edu.stanford.spezi.core.design.component + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.focus.onFocusEvent +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +fun Modifier.bringIntoViewOnFocusedEvent() = this then Modifier.composed { + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val coroutineScope = rememberCoroutineScope() + onFocusEvent { focusState -> + if (focusState.isFocused) { + coroutineScope.launch { + bringIntoViewRequester.bringIntoView() + } + } + } +} diff --git a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/theme/Colors.kt b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/theme/Colors.kt index 4b5e936b1..dfe41bd8c 100644 --- a/core/design/src/main/kotlin/edu/stanford/spezi/core/design/theme/Colors.kt +++ b/core/design/src/main/kotlin/edu/stanford/spezi/core/design/theme/Colors.kt @@ -71,6 +71,36 @@ object Colors { @Composable @ReadOnlyComposable get() = Color.Transparent + + val white + @Composable + @ReadOnlyComposable + get() = White + + val black + @Composable + @ReadOnlyComposable + get() = Black + + val black20 + @Composable + @ReadOnlyComposable + get() = Black20 + + val black40 + @Composable + @ReadOnlyComposable + get() = Black40 + + val black80 + @Composable + @ReadOnlyComposable + get() = Black80 + + val cardinalRedLight + @Composable + @ReadOnlyComposable + get() = CardinalRedLight } @Suppress("unused") diff --git a/core/design/src/main/res/values/strings.xml b/core/design/src/main/res/values/strings.xml new file mode 100644 index 000000000..651ce1443 --- /dev/null +++ b/core/design/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + OK + Cancel + \ No newline at end of file diff --git a/heartbeat-app/src/main/ic_launcher-playstore.png b/heartbeat-app/src/main/ic_launcher-playstore.png new file mode 100644 index 000000000..8b8efbc5d Binary files /dev/null and b/heartbeat-app/src/main/ic_launcher-playstore.png differ diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/MainApplication.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/MainApplication.kt index 67e3e36d2..757001635 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/MainApplication.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/MainApplication.kt @@ -2,6 +2,14 @@ package edu.stanford.bdh.heartbeat.app import android.app.Application import dagger.hilt.android.HiltAndroidApp +import edu.stanford.spezi.core.logging.SpeziLogger @HiltAndroidApp -class MainApplication : Application() +class MainApplication : Application() { + + override fun onCreate() { + super.onCreate() + + SpeziLogger.setLoggingEnabled(enabled = BuildConfig.DEBUG) + } +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/account/AccountManager.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/account/AccountManager.kt index dce7ffdef..190e82f83 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/account/AccountManager.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/account/AccountManager.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import javax.inject.Inject +import javax.inject.Singleton data class AccountInfo( val email: String, @@ -17,13 +18,55 @@ data class AccountInfo( val isEmailVerified: Boolean, ) -class AccountManager @Inject internal constructor( +interface AccountManager { + fun observeAccountInfo(): Flow + + suspend fun reloadAccountInfo(): Result + + suspend fun getToken(): Result + + suspend fun deleteCurrentUser(): Result + + suspend fun signOut(): Result + + suspend fun signUpWithEmailAndPassword( + email: String, + password: String, + ): Result + + suspend fun sendForgotPasswordEmail(email: String): Result + + suspend fun sendVerificationEmail(): Result + + suspend fun signIn(email: String, password: String): Result +} + +@Singleton +class AccountManagerImpl @Inject internal constructor( private val firebaseAuth: FirebaseAuth, @Dispatching.IO private val coroutineScope: CoroutineScope, -) { +) : AccountManager { private val logger by speziLogger() + private var userToken: String? = null + + init { + observeUserTokenChanges() + } - fun observeAccountInfo(): Flow = callbackFlow { + private fun observeUserTokenChanges() { + firebaseAuth.addIdTokenListener { auth: FirebaseAuth -> + val currentUser = auth.currentUser + if (currentUser == null) { + userToken = null + } else { + auth.currentUser?.getIdToken(false)?.addOnSuccessListener { result -> + userToken = result.token + } + } + } + } + + override fun observeAccountInfo(): Flow = callbackFlow { val authStateListener = FirebaseAuth.AuthStateListener { _ -> coroutineScope.launch { send(getAccountInfo()) } } @@ -35,44 +78,46 @@ class AccountManager @Inject internal constructor( } } - fun getAccountInfo(): AccountInfo? { - return firebaseAuth.currentUser?.let { user -> - AccountInfo( - email = user.email ?: "", - name = user.displayName?.takeIf { it.isNotBlank() }, - isEmailVerified = user.isEmailVerified - ) - } + override suspend fun reloadAccountInfo(): Result = runCatching { + firebaseAuth.currentUser?.reload()?.await() + userToken = getToken(forceRefresh = true).getOrNull() + getAccountInfo() } - suspend fun getToken(forceRefresh: Boolean = false): Result { + override suspend fun getToken(): Result { + return getToken(forceRefresh = false) + } + + private suspend fun getToken(forceRefresh: Boolean): Result { + val currentReceivedToken = userToken.takeIf { forceRefresh.not() } + if (currentReceivedToken != null) return Result.success(currentReceivedToken) return runCatching { - val user = firebaseAuth.currentUser ?: error("Does not have a current user to get a token for") - val idToken = user.getIdToken(forceRefresh).await() - return@runCatching idToken.token ?: error("Id token refresh didn't include a token") + val user = + firebaseAuth.currentUser ?: error("Does not have a current user to get a token for") + val token = user.getIdToken(forceRefresh).await().token ?: error("Id token refresh didn't include a token") + userToken = token + token }.onFailure { - logger.e { "Failed to force refresh token" } + logger.e(it) { "Failed to retrieve token" } } } - suspend fun deleteCurrentUser(): Result { + override suspend fun deleteCurrentUser(): Result { return runCatching { - firebaseAuth.currentUser?.delete()?.await() - return@runCatching + firebaseAuth.currentUser?.delete()?.await() ?: error("User not available") + Unit }.onFailure { logger.e { "Failed to delete user." } } } - fun signOut() { - runCatching { - firebaseAuth.signOut() - }.onFailure { - logger.e { "Failed to sign out" } - } + override suspend fun signOut(): Result = runCatching { + firebaseAuth.signOut() + }.onFailure { + logger.e { "Failed to sign out" } } - suspend fun signUpWithEmailAndPassword( + override suspend fun signUpWithEmailAndPassword( email: String, password: String, ): Result { @@ -84,7 +129,7 @@ class AccountManager @Inject internal constructor( } } - suspend fun sendForgotPasswordEmail(email: String): Result { + override suspend fun sendForgotPasswordEmail(email: String): Result { return runCatching { firebaseAuth.sendPasswordResetEmail(email).await().let { } }.onFailure { e -> @@ -92,7 +137,7 @@ class AccountManager @Inject internal constructor( } } - suspend fun sendVerificationEmail(): Result { + override suspend fun sendVerificationEmail(): Result { return runCatching { firebaseAuth.currentUser?.sendEmailVerification()?.await() return@runCatching @@ -101,7 +146,7 @@ class AccountManager @Inject internal constructor( } } - suspend fun signIn(email: String, password: String): Result { + override suspend fun signIn(email: String, password: String): Result { return runCatching { val result = firebaseAuth.signInWithEmailAndPassword(email, password).await() if (result.user == null) error("Failed to sign in, returned null user") @@ -109,4 +154,14 @@ class AccountManager @Inject internal constructor( logger.e { "Error signing in with email and password: ${e.message}" } } } + + private fun getAccountInfo(): AccountInfo? { + return firebaseAuth.currentUser?.let { user -> + AccountInfo( + email = user.email ?: "", + name = user.displayName?.takeIf { it.isNotBlank() }, + isEmailVerified = user.isEmailVerified + ) + } + } } diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/account/LoginPage.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/account/LoginPage.kt index 015e2e67e..78aedf6be 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/account/LoginPage.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/account/LoginPage.kt @@ -1,8 +1,10 @@ package edu.stanford.bdh.heartbeat.app.account import androidx.compose.foundation.layout.Column +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.material3.Button import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -13,8 +15,9 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import edu.stanford.spezi.core.design.theme.Spacings +import edu.stanford.spezi.core.design.theme.TextStyles @Composable fun LoginPage() { @@ -40,7 +43,22 @@ private fun LoginPage( } } - Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Column(modifier = Modifier.fillMaxWidth().padding(Spacings.medium)) { + Text( + "Your Account", + style = TextStyles.headlineLarge, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + Spacer(modifier = Modifier.size(Spacings.medium)) + + Text( + "You may login to your existing account. Or create a new one if you don't have one already.", + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + + Spacer(modifier = Modifier.size(Spacings.medium)) + OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = uiState.username, diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/account/LoginViewModel.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/account/LoginViewModel.kt index e26176029..0350e7fe5 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/account/LoginViewModel.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/account/LoginViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import edu.stanford.bdh.heartbeat.app.choir.ChoirRepository import edu.stanford.bdh.heartbeat.app.choir.api.types.Participant +import edu.stanford.spezi.core.utils.TimeProvider import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -33,6 +34,7 @@ sealed interface LoginAction { class LoginViewModel @Inject constructor( private val accountManager: AccountManager, private val choirRepository: ChoirRepository, + private val timeProvider: TimeProvider, ) : ViewModel() { private val _uiState = MutableStateFlow(LoginUiState()) @@ -61,6 +63,7 @@ class LoginViewModel @Inject constructor( firstName = "", lastName = "", email = state.username, + created = timeProvider.nowInstant().toString(), ) ) } diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/account/RegisterViewModel.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/account/RegisterViewModel.kt index 143f4612b..ee96d9e0f 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/account/RegisterViewModel.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/account/RegisterViewModel.kt @@ -47,6 +47,7 @@ class RegisterViewModel @Inject constructor( isLoading = false, ) } + accountManager.sendVerificationEmail().getOrNull() } .onFailure { error -> _uiState.update { diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/ChoirRepository.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/ChoirRepository.kt index fae54f306..6a5cb4fc8 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/ChoirRepository.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/ChoirRepository.kt @@ -5,34 +5,72 @@ import edu.stanford.bdh.heartbeat.app.choir.api.types.AssessmentStep import edu.stanford.bdh.heartbeat.app.choir.api.types.AssessmentSubmit import edu.stanford.bdh.heartbeat.app.choir.api.types.Onboarding import edu.stanford.bdh.heartbeat.app.choir.api.types.Participant +import edu.stanford.spezi.core.logging.speziLogger +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer import retrofit2.Response import javax.inject.Inject -class ChoirRepository @Inject internal constructor( +interface ChoirRepository { + suspend fun putParticipant(participant: Participant): Result + suspend fun unenrollParticipant(): Result + suspend fun getOnboarding(): Result + suspend fun continueAssessment(token: String, submit: AssessmentSubmit): Result +} + +class ChoirRepositoryImpl @Inject internal constructor( private val api: ChoirApi, -) { - companion object { - const val SITE_ID = "afib" +) : ChoirRepository { + private val logger by speziLogger() + + private val json = Json { + prettyPrint = true } - suspend fun putParticipant(participant: Participant) { - return body(api.putParticipant(SITE_ID, participant)) + override suspend fun putParticipant(participant: Participant): Result { + logger.i { "Invoking putParticipant for ${json(participant)}" } + return result(api.putParticipant(SITE_ID, participant)) } - suspend fun unenrollParticipant() { - return body(api.unenrollParticipant(SITE_ID)) + override suspend fun unenrollParticipant(): Result { + logger.i { "Invoking unrollParticipant" } + return result(api.unenrollParticipant(SITE_ID)) } - suspend fun getOnboarding(): Onboarding { - return body(api.getOnboarding(SITE_ID)) + override suspend fun getOnboarding(): Result { + logger.i { "Invoking getOnboarding" } + return result(api.getOnboarding(SITE_ID)) } - suspend fun continueAssessment(token: String, submit: AssessmentSubmit): AssessmentStep { - return body(api.continueAssessment(SITE_ID, token, submit)) + override suspend fun continueAssessment( + token: String, + submit: AssessmentSubmit, + ): Result { + logger.i { "Invoking continueAssessment with $token and ${json(submit)}" } + return result(api.continueAssessment(SITE_ID, token, submit)) } - private fun body(response: Response): T { - return response.body() - ?: error(response.errorBody()?.string() ?: "Unknown API error.") + private inline fun result(response: Response): Result { + return if (response.isSuccessful) { + response.body()?.let { + logger.i { "Returning successful response ${json(it)}" } + Result.success(it) + } ?: Result.failure(Throwable("Empty response body.")).also { + logger.i { "Returning error response with empty body" } + } + } else { + val errorMessage = response.errorBody()?.string() ?: "Unknown API error." + val statusCode = response.code() + val message = response.message() + val error = Throwable("HTTP $statusCode: $errorMessage, message: $message") + logger.i { "Received error response $error" } + Result.failure(error) + } + } + + private inline fun json(value: T) = json.encodeToString(serializer(), value) + + private companion object { + const val SITE_ID = "afib" } } diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/ChoirApi.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/ChoirApi.kt index 333408e9d..9c066b7b9 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/ChoirApi.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/ChoirApi.kt @@ -29,7 +29,7 @@ interface ChoirApi { @Path("siteId") siteId: String, ): Response - @POST("sites/{siteId}/assessment/{assessmentToken}/continue") + @POST("sites/{siteId}/assessments/{assessmentToken}/continue") suspend fun continueAssessment( @Path("siteId") siteId: String, @Path("assessmentToken") assessmentToken: String, diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/ChoirAuthenticationInterceptor.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/ChoirAuthenticationInterceptor.kt index 6c64f9ee7..cacd2d21f 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/ChoirAuthenticationInterceptor.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/ChoirAuthenticationInterceptor.kt @@ -9,12 +9,15 @@ import javax.inject.Inject class ChoirAuthenticationInterceptor @Inject constructor( private val accountManager: AccountManager, ) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { - val token: String = runBlocking { - accountManager.getToken(forceRefresh = false).getOrThrow() + val token: String? = runBlocking { + accountManager.getToken().getOrNull() } val request = chain.request().newBuilder() - request.addHeader("Authorization", "Bearer $token") + token?.let { + request.addHeader("Authorization", "Bearer $it") + } return chain.proceed(request.build()) } } diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/AssessmentStep.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/AssessmentStep.kt index 2addfda15..96b3c5e06 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/AssessmentStep.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/AssessmentStep.kt @@ -1,16 +1,9 @@ package edu.stanford.bdh.heartbeat.app.choir.api.types -import android.annotation.SuppressLint import kotlinx.serialization.Serializable @Serializable -@SuppressLint("UnsafeOptInUsageError") data class AssessmentStep( val displayStatus: DisplayStatus, - val question: QuestionPayload, -) { - @Serializable - data class QuestionPayload( - val value1: FormQuestion? = null, - ) -} + val question: FormQuestion, +) diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/AssessmentSubmit.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/AssessmentSubmit.kt index 1eb291ac5..f9a81662b 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/AssessmentSubmit.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/AssessmentSubmit.kt @@ -1,16 +1,9 @@ package edu.stanford.bdh.heartbeat.app.choir.api.types -import android.annotation.SuppressLint import kotlinx.serialization.Serializable @Serializable -@SuppressLint("UnsafeOptInUsageError") data class AssessmentSubmit( val submitStatus: SubmitStatus?, - val answers: AnswersPayload, -) { - @Serializable - data class AnswersPayload( - val value1: FormAnswer? = null, - ) -} + val answers: FormAnswer?, +) diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/DisplayStatus.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/DisplayStatus.kt index 940cb69bf..0c0357424 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/DisplayStatus.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/DisplayStatus.kt @@ -1,11 +1,9 @@ package edu.stanford.bdh.heartbeat.app.choir.api.types -import android.annotation.SuppressLint import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -@SuppressLint("UnsafeOptInUsageError") data class DisplayStatus( val compatLevel: String? = null, val questionId: String, diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormAnswer.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormAnswer.kt index 1226ef2d0..084549875 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormAnswer.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormAnswer.kt @@ -1,10 +1,8 @@ package edu.stanford.bdh.heartbeat.app.choir.api.types -import android.annotation.SuppressLint import kotlinx.serialization.Serializable @Serializable -@SuppressLint("UnsafeOptInUsageError") data class FormAnswer( val fieldAnswers: List? = null, ) diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormField.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormField.kt index 874fa2fd1..4bf93b47a 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormField.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormField.kt @@ -1,11 +1,9 @@ package edu.stanford.bdh.heartbeat.app.choir.api.types -import android.annotation.SuppressLint import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -@SuppressLint("UnsafeOptInUsageError") data class FormField( val fieldId: String, val type: Type, @@ -16,19 +14,37 @@ data class FormField( // val attributes: Map?, TODO: Check whether we actually need this value - if not, we could skip it val values: List? = null, ) { + /** + * Implement questionnaire UI elements: + * Number (i.e. numeric input) + * Text Area (i.e. bigger text block), requires HTML -> UI conversion + * Check boxes (i.e. multi-selection) + * Radios (i.e. single-selection) + * Heading, requires HTML -> UI conversion + * Text, requires HTML -> UI conversion + * DatePicker + * Dropdown + */ @Serializable enum class Type { - @SerialName("number") NUMBER, + /* START - REQUIRED IMPL */ + @SerialName("number") NUMBER, // TextFormFieldItem | Style.NUMERIC - @SerialName("textArea") TEXT_AREA, + @SerialName("checkboxes") CHECKBOXES, // ChoicesFieldItem | Style.Checkboxes - @SerialName("checkboxes") CHECKBOXES, + @SerialName("radios") RADIOS, // ChoicesFieldItem | Style.Radios - @SerialName("radios") RADIOS, + @SerialName("heading") HEADING, // HeadingFieldItem - @SerialName("heading") HEADING, + @SerialName("text") TEXT, // TextFormFieldItem | Style.TEXT - @SerialName("text") TEXT, + @SerialName("dropdown") DROPDOWN, // ChoicesFieldItem | Style.Dropdown + + @SerialName("datePicker") DATE_PICKER, // DatePickerFormField + + @SerialName("textArea") TEXT_AREA, // TextFormFieldItem | Style.TEXT_AREA + + /* END - REQUIRED IMPL */ @SerialName("videoLink") VIDEO_LINK, @@ -38,10 +54,6 @@ data class FormField( @SerialName("collapsibleContentField") COLLAPSIBLE_CONTENT_FIELD, - @SerialName("datePicker") DATE_PICKER, - - @SerialName("dropdown") DROPDOWN, - @SerialName("radioSetGrid") RADIO_SET_GRID, @SerialName("formattedText") FORMATTED_TEXT, diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormFieldAnswer.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormFieldAnswer.kt index 961363ce3..055086d87 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormFieldAnswer.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormFieldAnswer.kt @@ -1,10 +1,8 @@ package edu.stanford.bdh.heartbeat.app.choir.api.types -import android.annotation.SuppressLint import kotlinx.serialization.Serializable @Serializable -@SuppressLint("UnsafeOptInUsageError") data class FormFieldAnswer( val fieldId: String, val choice: List, diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormFieldValue.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormFieldValue.kt index 91d7f8cf9..d152d4c92 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormFieldValue.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormFieldValue.kt @@ -1,10 +1,8 @@ package edu.stanford.bdh.heartbeat.app.choir.api.types -import android.annotation.SuppressLint import kotlinx.serialization.Serializable @Serializable -@SuppressLint("UnsafeOptInUsageError") data class FormFieldValue( val id: String, val label: String, diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormQuestion.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormQuestion.kt index 1aff83579..74d040862 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormQuestion.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/FormQuestion.kt @@ -1,10 +1,8 @@ package edu.stanford.bdh.heartbeat.app.choir.api.types -import android.annotation.SuppressLint import kotlinx.serialization.Serializable @Serializable -@SuppressLint("UnsafeOptInUsageError") data class FormQuestion( val title1: String, val title2: String? = null, diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/Onboarding.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/Onboarding.kt index 2f8aff1b6..605ef9d7a 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/Onboarding.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/Onboarding.kt @@ -1,10 +1,8 @@ package edu.stanford.bdh.heartbeat.app.choir.api.types -import android.annotation.SuppressLint import kotlinx.serialization.Serializable @Serializable -@SuppressLint("UnsafeOptInUsageError") data class Onboarding( val displayStatus: DisplayStatus, val question: FormQuestion, diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/Participant.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/Participant.kt index 85cbc05b3..812fa39be 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/Participant.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/Participant.kt @@ -1,17 +1,16 @@ package edu.stanford.bdh.heartbeat.app.choir.api.types -import android.annotation.SuppressLint import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -@SuppressLint("UnsafeOptInUsageError") data class Participant( val id: String? = null, val firstName: String, val lastName: String, val birthDate: String? = null, val email: String, + val created: String, val homePhone: String? = null, val mobilePhone: String? = null, val contactPreference: ContactPreference? = null, diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/SubmitStatus.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/SubmitStatus.kt index d7ccea8d5..63101497e 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/SubmitStatus.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/choir/api/types/SubmitStatus.kt @@ -1,23 +1,21 @@ package edu.stanford.bdh.heartbeat.app.choir.api.types -import android.annotation.SuppressLint import kotlinx.serialization.Serializable @Serializable -@SuppressLint("UnsafeOptInUsageError") data class SubmitStatus( val questionId: String, val questionType: QuestionType, - val stepNumber: Double, + val stepNumber: Int, val surveyProviderId: String? = null, val surveySectionId: String? = null, val surveySystemName: String? = null, val sessionToken: String? = null, - val callTimeMillis: Double? = null, - val renderTimeMillis: Double? = null, - val thinkTimeMillis: Double? = null, - val retryCount: Double? = null, + val callTimeMillis: Long? = null, + val renderTimeMillis: Long? = null, + val thinkTimeMillis: Long? = null, + val retryCount: Int? = null, val locale: String, val compatLevel: String? = null, - val backRequest: Boolean? = null, + val backRequest: Boolean? = null ) diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/di/AppModule.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/di/AppModule.kt index f1fabee7d..99e14c0e9 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/di/AppModule.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/di/AppModule.kt @@ -1,29 +1,52 @@ package edu.stanford.bdh.heartbeat.app.di import com.google.firebase.auth.FirebaseAuth +import dagger.Lazy import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import edu.stanford.bdh.heartbeat.app.account.AccountManager +import edu.stanford.bdh.heartbeat.app.account.AccountManagerImpl +import edu.stanford.bdh.heartbeat.app.choir.ChoirRepository +import edu.stanford.bdh.heartbeat.app.choir.ChoirRepositoryImpl +import edu.stanford.bdh.heartbeat.app.fake.FakeAccountManager +import edu.stanford.bdh.heartbeat.app.fake.FakeChoirRepository +import edu.stanford.bdh.heartbeat.app.fake.FakeConfigs import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -object AppModule { - // TODO: Technically, we may want to support emulators, but I don't actually see the point, - // since we cannot connect to the CHOIR servers anyways - private const val USE_FIREBASE_EMULATOR = false +class AppModule { @Provides @Singleton fun provideFirebaseAuth() = FirebaseAuth.getInstance().apply { if (USE_FIREBASE_EMULATOR) { - useEmulator(FirebaseEmulatorSettings.HOST, FirebaseEmulatorSettings.AUTH_PORT) + useEmulator(HOST, AUTH_PORT) } } - private object FirebaseEmulatorSettings { + private companion object { const val HOST = "10.0.2.2" const val AUTH_PORT = 9099 + private const val USE_FIREBASE_EMULATOR = false + } + + @Module + @InstallIn(SingletonComponent::class) + class ApiModule { + + @Provides + fun provideAccountManager( + impl: Lazy, + fake: Lazy, + ): AccountManager = (if (FakeConfigs.ENABLED) fake else impl).get() + + @Provides + fun provideChoirRepository( + impl: Lazy, + fake: Lazy, + ): ChoirRepository = (if (FakeConfigs.ENABLED) fake else impl).get() } } diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/fake/FakeAccountManager.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/fake/FakeAccountManager.kt new file mode 100644 index 000000000..3b4963a63 --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/fake/FakeAccountManager.kt @@ -0,0 +1,63 @@ +package edu.stanford.bdh.heartbeat.app.fake + +import edu.stanford.bdh.heartbeat.app.account.AccountInfo +import edu.stanford.bdh.heartbeat.app.account.AccountManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FakeAccountManager @Inject constructor() : AccountManager, FakeComponent { + private val defaultAccount = AccountInfo( + email = "fake-user@heartbeat-study.edu", + name = "Fake User", + isEmailVerified = FakeConfigs.EMAIL_VERIFIED, + ) + + private val accountState = MutableStateFlow(defaultAccount.takeIf { FakeConfigs.SKIP_LOGIN }) + + override fun observeAccountInfo(): Flow = accountState.asStateFlow() + + override suspend fun reloadAccountInfo(): Result { + delay() + val newState = defaultAccount.copy(isEmailVerified = true) + accountState.update { newState } + return success(newState) + } + + override suspend fun getToken(): Result { + return success("fake-user-token") + } + + override suspend fun deleteCurrentUser(): Result { + return success(Unit) + } + + override suspend fun signOut(): Result { + accountState.update { null } + return success(Unit) + } + + override suspend fun signUpWithEmailAndPassword(email: String, password: String): Result { + delay() + accountState.update { defaultAccount.copy(email = email) } + return success(Unit) + } + + override suspend fun sendForgotPasswordEmail(email: String): Result { + return success(Unit) + } + + override suspend fun sendVerificationEmail(): Result { + return success(Unit) + } + + override suspend fun signIn(email: String, password: String): Result { + return signUpWithEmailAndPassword(email, password) + } + + private fun success(value: T) = Result.success(value) +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/fake/FakeChoirRepository.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/fake/FakeChoirRepository.kt new file mode 100644 index 000000000..6ed415fdc --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/fake/FakeChoirRepository.kt @@ -0,0 +1,67 @@ +package edu.stanford.bdh.heartbeat.app.fake + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import edu.stanford.bdh.heartbeat.app.R +import edu.stanford.bdh.heartbeat.app.choir.ChoirRepository +import edu.stanford.bdh.heartbeat.app.choir.api.types.AssessmentStep +import edu.stanford.bdh.heartbeat.app.choir.api.types.AssessmentSubmit +import edu.stanford.bdh.heartbeat.app.choir.api.types.Onboarding +import edu.stanford.bdh.heartbeat.app.choir.api.types.Participant +import edu.stanford.spezi.core.logging.speziLogger +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +@Serializable +private data class FakeData( + val onboarding: Onboarding, + val assessmentSteps: List, +) + +@Singleton +class FakeChoirRepository @Inject constructor( + @ApplicationContext context: Context, +) : ChoirRepository, FakeComponent { + private val logger by speziLogger() + + private val fakeData by lazy { + context.resources.openRawResource(R.raw.fake_data) + .bufferedReader() + .use { it.readText() } + .let { Json.decodeFromString(it) } + } + private var nextAssessmentIndex = 0 + + override suspend fun putParticipant(participant: Participant): Result { + return success(Unit) + } + + override suspend fun unenrollParticipant(): Result { + return success(Unit) + } + + override suspend fun getOnboarding(): Result { + delay() + return success(fakeData.onboarding) + } + + override suspend fun continueAssessment( + token: String, + submit: AssessmentSubmit, + ): Result { + logger.i { "Processing answers: ${submit.answers?.fieldAnswers}" } + val index = if (submit.submitStatus?.backRequest == true) nextAssessmentIndex - 2 else nextAssessmentIndex + val result = fakeData + .assessmentSteps + .getOrNull(index) ?: return Result.failure(Error("Done")) + + delay() + return Result.success(result).also { + nextAssessmentIndex = fakeData.assessmentSteps.indexOf(result) + 1 + } + } + + private fun success(value: T) = Result.success(value) +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/fake/FakeComponent.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/fake/FakeComponent.kt new file mode 100644 index 000000000..937f0ec4b --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/fake/FakeComponent.kt @@ -0,0 +1,10 @@ +package edu.stanford.bdh.heartbeat.app.fake + +import java.util.concurrent.TimeUnit +import kotlin.random.Random + +interface FakeComponent { + suspend fun delay() { + kotlinx.coroutines.delay(timeMillis = Random.nextLong(TimeUnit.SECONDS.toMillis(FakeConfigs.MAX_DELAY_SECONDS))) + } +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/fake/FakeConfigs.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/fake/FakeConfigs.kt new file mode 100644 index 000000000..be0fa0ba1 --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/fake/FakeConfigs.kt @@ -0,0 +1,30 @@ +package edu.stanford.bdh.heartbeat.app.fake + +import edu.stanford.bdh.heartbeat.app.R + +object FakeConfigs { + /** + * If [true] simutales a fake flow with example questions from [R.raw.fake_data] + */ + const val ENABLED = true + + /** + * Indicates whether a dummy login / register flow should be used, otherwise survey starts immediately + */ + const val SKIP_LOGIN = false + + /** + * Max value in seconds that simulates async request durations to visualize loading states + */ + const val MAX_DELAY_SECONDS = 2L + + /** + * Whether dummy account should have the email already verified + */ + const val EMAIL_VERIFIED = false + + /** + * Set to true to ignore question validations and complete the flow + */ + const val FORCE_ENABLE_CONTINUE = false +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/main/MainPage.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/main/MainPage.kt index e8e82c7f1..fe1fb8ccb 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/main/MainPage.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/main/MainPage.kt @@ -12,15 +12,21 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.hilt.navigation.compose.hiltViewModel import edu.stanford.bdh.heartbeat.app.account.LoginPage import edu.stanford.bdh.heartbeat.app.home.HomePage -import edu.stanford.bdh.heartbeat.app.onboarding.OnboardingPage +import edu.stanford.bdh.heartbeat.app.survey.SurveyPage import edu.stanford.spezi.core.design.component.Button +import edu.stanford.spezi.core.design.component.CenteredBoxContent import edu.stanford.spezi.core.design.component.VerticalSpacer import edu.stanford.spezi.core.design.theme.Colors import edu.stanford.spezi.core.design.theme.Spacings +import edu.stanford.spezi.core.design.theme.SpeziTheme import edu.stanford.spezi.core.design.theme.TextStyles +import edu.stanford.spezi.core.design.theme.ThemePreviews @Composable fun MainPage() { @@ -34,7 +40,57 @@ private fun MainPage( uiState: MainUiState, onAction: (MainAction) -> Unit, ) { - if (uiState.showsSignOutDialog) { + when (uiState) { + MainUiState.Loading -> CircularProgressIndicator() + + MainUiState.Unauthenticated -> LoginPage() + is MainUiState.Authenticated.RequiresEmailVerification -> EmailVerification( + uiState = uiState, + onAction = onAction + ) + + MainUiState.HomePage -> HomePage() + MainUiState.Authenticated.Survey.LoadingFailed -> OnboardingLoadingError(onAction = onAction) + is MainUiState.Authenticated.Survey.Content -> SurveyPage(onboardingState = uiState) + } +} + +@Composable +fun OnboardingLoadingError(onAction: (MainAction) -> Unit) { + CenteredBoxContent { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(Spacings.large), + ) { + Text( + text = "Oops", + style = TextStyles.headlineLarge, + textAlign = TextAlign.Center, + ) + VerticalSpacer(height = Spacings.medium) + Text( + text = "An error occurred while loading your onboarding questionnaire", + textAlign = TextAlign.Center, + ) + VerticalSpacer(height = Spacings.medium) + Button( + onClick = { + onAction(MainAction.ReloadOnboarding) + }, + ) { + Text("Try again") + } + SignOutButton(onClick = { onAction(MainAction.SignOut) }) + } + } +} + +@Composable +private fun EmailVerification( + uiState: MainUiState.Authenticated.RequiresEmailVerification, + onAction: (MainAction) -> Unit, +) { + if (uiState.showSignoutDialog) { AlertDialog( onDismissRequest = { onAction(MainAction.ShowSignOutDialog(false)) @@ -66,48 +122,78 @@ private fun MainPage( ) } - if (uiState.accountInfo == null) { - LoginPage() - } else if (!uiState.accountInfo.isEmailVerified) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(Spacings.large), - ) { - Text("Email has been sent.", style = TextStyles.headlineLarge) - VerticalSpacer(height = Spacings.medium) - Text("Check your inbox and verify your email by clicking the provided link.") - VerticalSpacer(height = Spacings.medium) - TextButton( - onClick = { - onAction(MainAction.Reload) - } - ) { - Text("Reload") - } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(Spacings.large), + ) { + Text( + text = "Welcome", + style = TextStyles.headlineLarge, + textAlign = TextAlign.Center, + ) + VerticalSpacer(height = Spacings.medium) + Text( + text = "Check your inbox and verify your email by clicking the provided link.", + ) + VerticalSpacer(height = Spacings.medium) - Spacer(modifier = Modifier.weight(1f)) + @Suppress("MaxLineLength") + Text(text = "If you haven't received the email, you can resend it. After confirming, please press \"Reload\" to update your status.") + VerticalSpacer(height = Spacings.medium) - Button( - onClick = { - onAction(MainAction.ResendVerificationEmail) - }, - ) { - Text("Resend verification email") - } + Button( + onClick = { + onAction(MainAction.ResendVerificationEmail) + }, + ) { + Text("Resend verification email") + } - TextButton( - onClick = { - onAction(MainAction.ShowSignOutDialog(true)) - } - ) { - Text("Sign Out", color = Colors.error) - } + TextButton( + onClick = { onAction(MainAction.ReloadUser) } + ) { + Text("Reload") } - } else if (uiState.isLoadingOnboarding) { - CircularProgressIndicator() - } else if (uiState.hasFinishedOnboarding) { - HomePage() - } else { - OnboardingPage() + + Spacer(modifier = Modifier.weight(1f)) + SignOutButton(onClick = { onAction(MainAction.SignOut) }) + } +} + +@Composable +private fun SignOutButton( + onClick: () -> Unit, +) { + TextButton(onClick = onClick) { + Text("Sign Out", color = Colors.error) + } +} + +private class MainUiStatePreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + MainUiState.Loading, + MainUiState.Authenticated.Survey.LoadingFailed, + MainUiState.Authenticated.RequiresEmailVerification(false), + MainUiState.Authenticated.RequiresEmailVerification(true) + ) +} + +@ThemePreviews +@Composable +private fun Previews(@PreviewParameter(MainUiStatePreviewParameterProvider::class) state: MainUiState) { + SpeziTheme(isPreview = true) { + MainPage( + uiState = state, + onAction = {} + ) + } +} + +@ThemePreviews +@Composable +private fun OnboardingLoadingFailed() { + SpeziTheme(isPreview = true) { + EmailVerification(uiState = MainUiState.Authenticated.RequiresEmailVerification(false)) { } } } diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/main/MainViewModel.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/main/MainViewModel.kt index 587eee090..de7d6e1f4 100644 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/main/MainViewModel.kt +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/main/MainViewModel.kt @@ -6,21 +6,41 @@ import dagger.hilt.android.lifecycle.HiltViewModel import edu.stanford.bdh.heartbeat.app.account.AccountInfo import edu.stanford.bdh.heartbeat.app.account.AccountManager import edu.stanford.bdh.heartbeat.app.choir.ChoirRepository +import edu.stanford.bdh.heartbeat.app.choir.api.types.Onboarding +import edu.stanford.spezi.core.logging.speziLogger +import edu.stanford.spezi.core.utils.MessageNotifier import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject -data class MainUiState( - val accountInfo: AccountInfo? = null, - val isLoadingOnboarding: Boolean = false, - val hasFinishedOnboarding: Boolean = false, - val showsSignOutDialog: Boolean = false, -) +sealed interface MainUiState { + data object Loading : MainUiState + + data object Unauthenticated : MainUiState + + sealed interface Authenticated : MainUiState { + + data class RequiresEmailVerification( + val showSignoutDialog: Boolean, + ) : Authenticated + + sealed interface Survey : Authenticated { + data object LoadingFailed : Survey + data class Content( + val onboarding: Onboarding, + val onCompleted: () -> Unit, + ) : Survey + } + } + + data object HomePage : MainUiState +} sealed interface MainAction { - data object Reload : MainAction + data object ReloadOnboarding : MainAction + data object ReloadUser : MainAction data object ResendVerificationEmail : MainAction data class ShowSignOutDialog(val value: Boolean) : MainAction data object SignOut : MainAction @@ -30,75 +50,115 @@ sealed interface MainAction { class MainViewModel @Inject constructor( private val accountManager: AccountManager, private val choirRepository: ChoirRepository, + private val messageNotifier: MessageNotifier, ) : ViewModel() { - private val _uiState = MutableStateFlow(MainUiState()) + private val logger by speziLogger() + private val _uiState = MutableStateFlow(MainUiState.Loading) val uiState = _uiState.asStateFlow() init { viewModelScope.launch { accountManager.observeAccountInfo().collect { accountInfo -> - val previousAccountInfo = _uiState.value.accountInfo - _uiState.update { - it.copy(accountInfo = accountInfo) - } - if (previousAccountInfo == null && accountInfo != null) { - handleReload() - } + logger.i { "Received new account info update $accountInfo" } + update(accountInfo = accountInfo) } } } fun onAction(action: MainAction) { when (action) { - is MainAction.Reload -> - handleReload() - is MainAction.ResendVerificationEmail -> - handleResendVerificationEmail() + is MainAction.ReloadUser -> { + viewModelScope.launch { + val previousState = _uiState.value + _uiState.update { MainUiState.Loading } + accountManager.reloadAccountInfo() + .onSuccess { update(accountInfo = it) } + .onFailure { + messageNotifier.notify("An error occurred while reloading your status") + _uiState.update { previousState } + } + } + } + + is MainAction.ReloadOnboarding -> { + viewModelScope.launch { + _uiState.update { MainUiState.Loading } + accountManager.reloadAccountInfo() + loadOnboarding() + } + } + + is MainAction.ResendVerificationEmail -> viewModelScope.launch { + accountManager.sendVerificationEmail() + .onSuccess { + val message = "Verification email sent!" + logger.i { message } + messageNotifier.notify(message) + } + .onFailure { + val message = "Failed to send verification email!" + logger.e(it) { message } + messageNotifier.notify("Failed to send verification email!") + } + } + is MainAction.ShowSignOutDialog -> - _uiState.update { it.copy(showsSignOutDialog = action.value) } - is MainAction.SignOut -> - handleSignOut() + _uiState.update { currentState -> + if (currentState is MainUiState.Authenticated.RequiresEmailVerification) { + currentState.copy(showSignoutDialog = action.value) + } else { + currentState + } + } + + is MainAction.SignOut -> handleSignOut() } } - private fun handleResendVerificationEmail() { - viewModelScope.launch { - val accountInfo = accountManager.getAccountInfo() - _uiState.update { it.copy(accountInfo = accountInfo) } - if (accountInfo?.isEmailVerified == false) { - accountManager.sendVerificationEmail() + private fun update(accountInfo: AccountInfo?) { + when { + accountInfo == null -> _uiState.update { MainUiState.Unauthenticated } + !accountInfo.isEmailVerified -> _uiState.update { + MainUiState.Authenticated.RequiresEmailVerification( + showSignoutDialog = false + ) } + + else -> loadOnboarding() } } - private fun handleReload() { + private fun loadOnboarding() { viewModelScope.launch { - _uiState.update { it.copy(isLoadingOnboarding = true) } - runCatching { - choirRepository.getOnboarding() - }.onSuccess { onboarding -> - _uiState.update { - it.copy( - isLoadingOnboarding = false, - hasFinishedOnboarding = onboarding.question.terminal == true, - ) - } - }.onFailure { - _uiState.update { - it.copy( - isLoadingOnboarding = false, - ) + logger.i { "Invoking getOnboarding" } + _uiState.update { MainUiState.Loading } + choirRepository.getOnboarding() + .onSuccess { onboarding -> + logger.i { "Onboarding loaded successfully" } + _uiState.update { + MainUiState.Authenticated.Survey.Content( + onboarding = onboarding, + onCompleted = { + messageNotifier.notify("We appreciate your participation in the study!") + _uiState.update { MainUiState.HomePage } + } + ) + } + }.onFailure { + logger.e(it) { "Failed to load onboarding" } + _uiState.update { MainUiState.Authenticated.Survey.LoadingFailed } } - } } } private fun handleSignOut() { viewModelScope.launch { - runCatching { - accountManager.signOut() - } - _uiState.update { it.copy(showsSignOutDialog = false) } + accountManager.signOut() + .onSuccess { + _uiState.update { MainUiState.Unauthenticated } + }.onFailure { + messageNotifier.notify("Failed to sign out") + } } } } diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/onboarding/OnboardingAnswers.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/onboarding/OnboardingAnswers.kt deleted file mode 100644 index 49d79e0e5..000000000 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/onboarding/OnboardingAnswers.kt +++ /dev/null @@ -1,41 +0,0 @@ -package edu.stanford.bdh.heartbeat.app.onboarding - -import edu.stanford.bdh.heartbeat.app.choir.api.types.FormAnswer -import edu.stanford.bdh.heartbeat.app.choir.api.types.FormFieldAnswer - -data class OnboardingAnswers( - private val map: Map = emptyMap(), -) { - fun answer(id: String): AnswerFormat? = map[id] - - fun copyWithChange(id: String, answer: AnswerFormat) = - copy(map = map.toMutableMap().apply { this[id] = answer }) - - fun asFormAnswer() = FormAnswer( - fieldAnswers = map.map { - FormFieldAnswer( - fieldId = it.key, - choice = it.value.asStringList(), - ) - } - ) -} - -sealed interface AnswerFormat { - data class Text(val value: String?) : AnswerFormat - data class Numeric(val value: Double?) : AnswerFormat - data class Date(val value: java.util.Date?) : AnswerFormat - data class Weight(val value: Double?) : AnswerFormat - data class Height(val value: Double?) : AnswerFormat - data class MultipleChoice(val value: List?) : AnswerFormat - data class Image(val value: List?) : AnswerFormat - data class Scale(val value: Double?) : AnswerFormat - - fun asStringList(): List = TODO("Not yet implemented") -} - -sealed interface ResultValue { - data class Int(val value: kotlin.Int) - data class String(val value: kotlin.String) - data class Date(val value: java.util.Date) -} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/onboarding/OnboardingPage.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/onboarding/OnboardingPage.kt deleted file mode 100644 index ec27fc0e0..000000000 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/onboarding/OnboardingPage.kt +++ /dev/null @@ -1,30 +0,0 @@ -package edu.stanford.bdh.heartbeat.app.onboarding - -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.hilt.navigation.compose.hiltViewModel - -@Composable -fun OnboardingPage() { - val viewModel = hiltViewModel() - val uiState by viewModel.uiState.collectAsState() - OnboardingPage(uiState, viewModel::onAction) -} - -@Composable -private fun OnboardingPage( - uiState: OnboardingUiState, - onAction: (OnboardingAction) -> Unit, -) { - LaunchedEffect(Unit) { - onAction(OnboardingAction.Reload) - } - - Column { - Text("State: $uiState") - } -} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/onboarding/OnboardingViewModel.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/onboarding/OnboardingViewModel.kt deleted file mode 100644 index 1b24be550..000000000 --- a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/onboarding/OnboardingViewModel.kt +++ /dev/null @@ -1,144 +0,0 @@ -package edu.stanford.bdh.heartbeat.app.onboarding - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import edu.stanford.bdh.heartbeat.app.choir.ChoirRepository -import edu.stanford.bdh.heartbeat.app.choir.api.types.AssessmentStep -import edu.stanford.bdh.heartbeat.app.choir.api.types.AssessmentSubmit -import edu.stanford.bdh.heartbeat.app.choir.api.types.QuestionType -import edu.stanford.bdh.heartbeat.app.choir.api.types.SubmitStatus -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -data class OnboardingUiState( - val surveyToken: String? = null, - val step: AssessmentStep? = null, - val isLoading: Boolean = false, - val answers: OnboardingAnswers = OnboardingAnswers(), - val showsHandlingOnboardingAlert: Boolean = false, - val showsAssessmentContinueAlert: Boolean = false, - val isContinueButtonEnabled: Boolean = false, - val errorMessage: String? = null, -) - -sealed interface OnboardingAction { - data class ChangeAnswer(val id: String, val answer: AnswerFormat) : OnboardingAction - data object Reload : OnboardingAction - data object Continue : OnboardingAction - data object Back : OnboardingAction -} - -@HiltViewModel -class OnboardingViewModel @Inject constructor( - private val repository: ChoirRepository, -) : ViewModel() { - private val _uiState = MutableStateFlow(OnboardingUiState()) - val uiState = _uiState.asStateFlow() - - fun onAction(action: OnboardingAction) { - when (action) { - is OnboardingAction.ChangeAnswer -> - handleChangeAnswer(action) - is OnboardingAction.Reload -> - handleReload() - is OnboardingAction.Continue -> - handleContinue(backRequest = false) - is OnboardingAction.Back -> - handleContinue(backRequest = true) - } - } - - private fun handleChangeAnswer(action: OnboardingAction.ChangeAnswer) { - _uiState.update { state -> - state.copy( - answers = state.answers.copyWithChange( - id = action.id, - answer = action.answer - ), - isContinueButtonEnabled = state.step?.question?.value1?.fields?.all { - it.required != true || state.answers.answer(it.fieldId) != null - } == true - ) - } - } - - private fun handleReload() { - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } - runCatching { - repository.getOnboarding() - }.onSuccess { onboarding -> - _uiState.update { - it.copy( - surveyToken = onboarding.displayStatus.surveyToken, - step = AssessmentStep( - displayStatus = onboarding.displayStatus, - question = AssessmentStep.QuestionPayload( - value1 = onboarding.question - ) - ), - isLoading = false - ) - } - }.onFailure { error -> - _uiState.update { - it.copy( - showsAssessmentContinueAlert = true, - isContinueButtonEnabled = false, - errorMessage = error.message ?: "An unknown error occurred.", - isLoading = false - ) - } - } - } - } - - private fun handleContinue(backRequest: Boolean) { - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } - val state = _uiState.value - runCatching { - repository.continueAssessment( - token = state.surveyToken ?: error("No survey token available."), - submit = AssessmentSubmit( - submitStatus = state.step?.let { step -> - SubmitStatus( - questionId = step.displayStatus.questionId, - questionType = QuestionType.FORM, - stepNumber = (step.displayStatus.stepNumber ?: "0").toDoubleOrNull() ?: 0.0, - surveySectionId = step.displayStatus.surveySectionId, - sessionToken = step.displayStatus.sessionToken, - locale = step.displayStatus.locale, - backRequest = backRequest - ) - }, - answers = AssessmentSubmit.AnswersPayload( - value1 = state.answers.asFormAnswer() - ) - ) - ) - }.onSuccess { success -> - _uiState.update { - it.copy( - step = success, - answers = OnboardingAnswers(), - isLoading = false - ) - } - }.onFailure { error -> - _uiState.update { - it.copy( - errorMessage = error.message ?: "An unknown error occurred.", - showsAssessmentContinueAlert = true, - isContinueButtonEnabled = false, - isLoading = false, - ) - } - } - } - } -} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/SurveyPage.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/SurveyPage.kt new file mode 100644 index 000000000..63a6f942b --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/SurveyPage.kt @@ -0,0 +1,19 @@ +package edu.stanford.bdh.heartbeat.app.survey + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import edu.stanford.bdh.heartbeat.app.main.MainUiState + +@Composable +fun SurveyPage(onboardingState: MainUiState.Authenticated.Survey.Content) { + val viewModel = hiltViewModel( + creationCallback = { factory -> factory.create(onboardingState) }, + key = onboardingState.onboarding.displayStatus.surveyToken + ) + val uiState by viewModel.uiState.collectAsState() + uiState.Content(modifier = Modifier.fillMaxSize()) +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/SurveyUiStateMapper.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/SurveyUiStateMapper.kt new file mode 100644 index 000000000..65461a043 --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/SurveyUiStateMapper.kt @@ -0,0 +1,158 @@ +package edu.stanford.bdh.heartbeat.app.survey + +import edu.stanford.bdh.heartbeat.app.choir.api.types.AssessmentStep +import edu.stanford.bdh.heartbeat.app.choir.api.types.FormField +import edu.stanford.bdh.heartbeat.app.fake.FakeConfigs +import edu.stanford.bdh.heartbeat.app.survey.ui.QuestionButton +import edu.stanford.bdh.heartbeat.app.survey.ui.QuestionFieldLabel +import edu.stanford.bdh.heartbeat.app.survey.ui.QuestionNumberInfo +import edu.stanford.bdh.heartbeat.app.survey.ui.SurveyProgress +import edu.stanford.bdh.heartbeat.app.survey.ui.SurveyQuestionState +import edu.stanford.bdh.heartbeat.app.survey.ui.SurveyQuestionTitle +import edu.stanford.bdh.heartbeat.app.survey.ui.SurveyUiState +import edu.stanford.bdh.heartbeat.app.survey.ui.fields.ChoicesFormFieldItem +import edu.stanford.bdh.heartbeat.app.survey.ui.fields.DatePickerFormFieldItem +import edu.stanford.bdh.heartbeat.app.survey.ui.fields.FormFieldItem +import edu.stanford.bdh.heartbeat.app.survey.ui.fields.HeadingFormFieldItem +import edu.stanford.bdh.heartbeat.app.survey.ui.fields.TextFormFieldItem +import edu.stanford.bdh.heartbeat.app.survey.ui.fields.UnsupportedFormFieldItem +import javax.inject.Inject + +class SurveyUiStateMapper @Inject constructor() { + + fun map( + assessmentStep: AssessmentStep, + onAction: (SurveyAction) -> Unit, + ): SurveyUiState { + val question = assessmentStep.question + val displayStatus = assessmentStep.displayStatus + val questionFields = question.fields ?: emptyList() + return SurveyUiState( + pageTitle = displayStatus.pageTitle ?: "Heartbeat Study", + questionState = SurveyQuestionState.Question( + progress = SurveyProgress(value = ((displayStatus.progress ?: 0.0) / 100.0).toFloat()), + title = SurveyQuestionTitle(question.title1), + secondaryTitle = question.title2?.let { SurveyQuestionTitle(it) }, + fields = mapFormFields(formFields = questionFields, onAction = onAction), + backButton = if (displayStatus.showBack == true) { + QuestionButton( + title = "Back", + onClick = { onAction(SurveyAction.Back) }, + enabled = true + ) + } else { + null + }, + continueButton = QuestionButton( + title = if (question.terminal == true) "Finish" else "Continue", + onClick = { onAction(SurveyAction.Continue) }, + enabled = FakeConfigs.FORCE_ENABLE_CONTINUE || questionFields.none { it.required == true } + ), + onDisplayed = { onAction(SurveyAction.QuestionRendered) } + ) + ) + } + + private fun mapFormFields( + formFields: List, + onAction: (SurveyAction) -> Unit, + ): List { + return formFields.mapIndexed { index, formField -> + val info = QuestionNumberInfo(current = index + 1, total = formFields.size) + val fieldLabel = + formField.label?.takeIf { it.isNotEmpty() }?.let { QuestionFieldLabel(it) } + val fieldId = formField.fieldId + + when (formField.type) { + FormField.Type.NUMBER, + FormField.Type.TEXT, + FormField.Type.TEXT_AREA, + -> TextFormFieldItem( + fieldId = fieldId, + info = info, + fieldLabel = fieldLabel, + warning = mapWarning(formField = formField), + displayWarning = false, + style = when (formField.type) { + FormField.Type.TEXT_AREA -> TextFormFieldItem.Style.TEXT_AREA + FormField.Type.NUMBER -> TextFormFieldItem.Style.NUMERIC + else -> TextFormFieldItem.Style.TEXT + }, + value = "", + onValueChange = { + onAction(SurveyAction.Update(fieldId = fieldId, answer = AnswerUpdate.Text(it))) + } + ) + + FormField.Type.HEADING -> HeadingFormFieldItem( + fieldId = fieldId, + text = formField.label + ) + + FormField.Type.DATE_PICKER -> DatePickerFormFieldItem( + fieldId = fieldId, + info = info, + fieldLabel = fieldLabel, + value = "", + onValueChange = { onAction(SurveyAction.Update(fieldId, AnswerUpdate.Date(it))) } + ) + + FormField.Type.CHECKBOXES, FormField.Type.RADIOS, FormField.Type.DROPDOWN -> + mapChoiceField( + formField = formField, + info = info, + fieldLabel = fieldLabel, + onAction = onAction + ) + + else -> UnsupportedFormFieldItem( + fieldId = fieldId, + type = formField.type.name, + info = info, + fieldLabel = fieldLabel, + ) + } + } + } + + private fun mapChoiceField( + formField: FormField, + info: QuestionNumberInfo, + fieldLabel: QuestionFieldLabel?, + onAction: (SurveyAction) -> Unit, + ): ChoicesFormFieldItem { + val style = when (formField.type) { + FormField.Type.CHECKBOXES -> ChoicesFormFieldItem.Style.Checkboxes + FormField.Type.RADIOS -> ChoicesFormFieldItem.Style.Radios + FormField.Type.DROPDOWN -> ChoicesFormFieldItem.Style.Dropdown( + label = "Select an option...", + initialExpanded = false + ) + + else -> error("Unsupported choice type: ${formField.type}") + } + return ChoicesFormFieldItem( + fieldId = formField.fieldId, + info = info, + fieldLabel = fieldLabel, + style = style, + selectedIds = emptySet(), + options = formField.values?.map { ChoicesFormFieldItem.Option(it.id, it.label) } + ?: emptyList(), + onOptionClicked = { + onAction(SurveyAction.Update(formField.fieldId, AnswerUpdate.OptionId(it))) + } + ) + } + + private fun mapWarning(formField: FormField) = if (formField.type == FormField.Type.NUMBER) { + val min = formField.min + val max = formField.max + val rangeInfo = if (min != null && max != null) " from $min to $max." else "" + "Please enter a valid number$rangeInfo" + } else if (formField.required == true) { + "This answer is required!" + } else { + null + } +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/SurveyViewModel.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/SurveyViewModel.kt new file mode 100644 index 000000000..ae758cb8d --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/SurveyViewModel.kt @@ -0,0 +1,280 @@ +package edu.stanford.bdh.heartbeat.app.survey + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import edu.stanford.bdh.heartbeat.app.choir.ChoirRepository +import edu.stanford.bdh.heartbeat.app.choir.api.types.AssessmentStep +import edu.stanford.bdh.heartbeat.app.choir.api.types.AssessmentSubmit +import edu.stanford.bdh.heartbeat.app.choir.api.types.FormAnswer +import edu.stanford.bdh.heartbeat.app.choir.api.types.FormField +import edu.stanford.bdh.heartbeat.app.choir.api.types.FormFieldAnswer +import edu.stanford.bdh.heartbeat.app.choir.api.types.SubmitStatus +import edu.stanford.bdh.heartbeat.app.fake.FakeConfigs +import edu.stanford.bdh.heartbeat.app.main.MainUiState +import edu.stanford.bdh.heartbeat.app.survey.ui.SurveyQuestionState +import edu.stanford.bdh.heartbeat.app.survey.ui.fields.ChoicesFormFieldItem +import edu.stanford.bdh.heartbeat.app.survey.ui.fields.DatePickerFormFieldItem +import edu.stanford.bdh.heartbeat.app.survey.ui.fields.TextFormFieldItem +import edu.stanford.spezi.core.logging.speziLogger +import edu.stanford.spezi.core.utils.DateFormat +import edu.stanford.spezi.core.utils.DateFormatter +import edu.stanford.spezi.core.utils.MessageNotifier +import edu.stanford.spezi.core.utils.TimeProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneId + +sealed interface SurveyAction { + data object QuestionRendered : SurveyAction + data class Update(val fieldId: String, val answer: AnswerUpdate) : SurveyAction + data object Continue : SurveyAction + data object Back : SurveyAction +} + +sealed interface AnswerUpdate { + data class OptionId(val value: String) : AnswerUpdate + data class Text(val value: String) : AnswerUpdate + data class Date(val value: Instant) : AnswerUpdate +} + +@HiltViewModel(assistedFactory = SurveyViewModel.Factory::class) +class SurveyViewModel @AssistedInject constructor( + @Assisted private val state: MainUiState.Authenticated.Survey.Content, + private val repository: ChoirRepository, + private val surveyUiStateMapper: SurveyUiStateMapper, + private val messageNotifier: MessageNotifier, + private val dateFormatter: DateFormatter, + private val timeProvider: TimeProvider, +) : ViewModel() { + private val logger by speziLogger() + + private var currentAssessmentStep = with(state.onboarding) { + AssessmentStep( + question = question, + displayStatus = displayStatus + ) + } + private val session = Session().apply { setup(assessmentStep = currentAssessmentStep) } + + private val _uiState = MutableStateFlow( + surveyUiStateMapper.map( + assessmentStep = currentAssessmentStep, + onAction = ::onAction + ) + ) + + val uiState = _uiState.asStateFlow() + + private fun onAction(action: SurveyAction) { + when (action) { + is SurveyAction.QuestionRendered -> session.questionRendered() + is SurveyAction.Update -> handleUpdate(action) + + is SurveyAction.Continue -> { + if (currentAssessmentStep.question.terminal == true) { + state.onCompleted() + } else { + handleContinue(backRequest = false) + } + } + + is SurveyAction.Back -> handleContinue(backRequest = true) + } + } + + private fun handleUpdate(action: SurveyAction.Update) { + val questionsState = _uiState.value.questionState as? SurveyQuestionState.Question ?: return + val fieldMap = questionsState.fields.associateBy { it.fieldId }.toMutableMap() + val fieldId = action.fieldId + val fieldItem = fieldMap[fieldId] + val answer = action.answer + val answerValue = stringValue(answer = answer) + when (fieldItem) { + is TextFormFieldItem -> { + val isValidAnswer = isValidAnswer(value = answerValue, fieldItem = fieldItem) + val sanitized = answerValue.takeIf { isValidAnswer } + session.store(fieldId = fieldId, answer = sanitized) + fieldMap[fieldId] = fieldItem.copy( + value = answerValue, + displayWarning = isValidAnswer.not() + ) + } + + is ChoicesFormFieldItem -> { + var style = fieldItem.style + val newSelectedIds = when (style) { + is ChoicesFormFieldItem.Style.Checkboxes -> { + fieldItem.selectedIds.toMutableSet().apply { + if (!add(answerValue)) remove(answerValue) + } + } + + is ChoicesFormFieldItem.Style.Radios -> setOf(answerValue) + is ChoicesFormFieldItem.Style.Dropdown -> { + val newLabel = fieldItem.options.find { it.id == answerValue }?.label + style = style.copy(label = newLabel ?: style.label) + setOf(answerValue) + } + } + session.store(fieldId = fieldId, answers = newSelectedIds) + fieldMap[fieldId] = fieldItem.copy( + selectedIds = newSelectedIds, + style = style + ) + } + + is DatePickerFormFieldItem -> { + session.store(fieldId = fieldId, answer = answerValue) + fieldMap[fieldId] = fieldItem.copy(value = answerValue) + } + + else -> return + } + + _uiState.update { + it.copy( + questionState = questionsState.copy( + fields = fieldMap.values.toList(), + continueButton = questionsState.continueButton.copy( + enabled = session.isContinueAllowed() + ) + ), + ) + } + } + + private fun handleContinue(backRequest: Boolean) { + viewModelScope.launch { + val currentQuestionsState = _uiState.value.questionState + _uiState.update { it.copy(questionState = SurveyQuestionState.Loading) } + val displayStatus = currentAssessmentStep.displayStatus + + repository.continueAssessment( + token = displayStatus.surveyToken ?: "", + submit = AssessmentSubmit( + submitStatus = SubmitStatus( + questionId = displayStatus.questionId, + questionType = displayStatus.questionType, + stepNumber = displayStatus.stepNumber?.toIntOrNull() ?: 1, + surveySectionId = displayStatus.surveySectionId, + renderTimeMillis = session.renderTimeMillis, + retryCount = session.retryCount, + thinkTimeMillis = session.getThinkingTime(), + sessionToken = displayStatus.sessionToken, + locale = displayStatus.locale, + backRequest = backRequest + ), + answers = FormAnswer( + fieldAnswers = session.choices.map { entry -> + FormFieldAnswer( + fieldId = entry.key, + choice = entry.value.toList() + ) + } + ) + ) + ).onSuccess { success -> + currentAssessmentStep = success + session.setup(assessmentStep = success) + _uiState.update { + surveyUiStateMapper.map( + assessmentStep = success, + onAction = ::onAction + ) + } + }.onFailure { error -> + session.retryCount++ + logger.e(error) { "Failure while submitting the answer" } + messageNotifier.notify("An error occurred when submitting your answer") + _uiState.update { it.copy(questionState = currentQuestionsState) } + } + } + } + + private fun stringValue(answer: AnswerUpdate) = when (answer) { + is AnswerUpdate.Text -> answer.value + is AnswerUpdate.OptionId -> answer.value + // TODO: This is the formatted displayed date, also sent as choice. + // Clarify expected date format from backend. + is AnswerUpdate.Date -> dateFormatter.format( + instant = answer.value, + format = DateFormat.MM_DD_YYYY, + zoneId = ZoneId.of("UTC") + ) + } + + private fun isValidAnswer(value: String, fieldItem: TextFormFieldItem): Boolean { + val formField = session.formFields[fieldItem.fieldId] + return when { + fieldItem.style == TextFormFieldItem.Style.NUMERIC -> { + val answerValue = value.toDoubleOrNull() ?: return false + val min = formField?.min?.toDoubleOrNull() ?: Double.MIN_VALUE + val max = formField?.max?.toDoubleOrNull() ?: Double.MAX_VALUE + answerValue in min..max + } + + formField?.required == true && value.isBlank() -> false + else -> true + } + } + + private inner class Session { + val formFields = mutableMapOf() + val requiredFields = hashSetOf() + val choices = mutableMapOf>() + private var receivedAt: Long? = null + var retryCount = 0 + var renderTimeMillis: Long? = null + + fun setup(assessmentStep: AssessmentStep) { + receivedAt = timeProvider.currentTimeMillis() + retryCount = 0 + renderTimeMillis = null + requiredFields.clear() + choices.clear() + formFields.clear() + assessmentStep.question.fields?.forEach { + formFields[it.fieldId] = it + if (it.required == true) requiredFields.add(it.fieldId) + } + } + + fun store(fieldId: String, answer: String?) { + if (answer.isNullOrEmpty()) { + choices.remove(fieldId) + } else { + choices[fieldId] = + setOf(answer) + } + } + + fun store(fieldId: String, answers: Set) { + if (answers.isEmpty()) choices.remove(fieldId) else choices[fieldId] = answers + } + + fun isContinueAllowed() = FakeConfigs.FORCE_ENABLE_CONTINUE || + requiredFields.all { id -> choices[id]?.isNotEmpty() == true } + + fun getThinkingTime() = receivedAt?.let { + timeProvider.currentTimeMillis() - it + } + + fun questionRendered() { + renderTimeMillis = receivedAt?.let { timeProvider.currentTimeMillis() - it } + } + } + + @AssistedFactory + interface Factory { + fun create( + content: MainUiState.Authenticated.Survey.Content, + ): SurveyViewModel + } +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/HtmlText.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/HtmlText.kt new file mode 100644 index 000000000..9c4ff3324 --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/HtmlText.kt @@ -0,0 +1,125 @@ +package edu.stanford.bdh.heartbeat.app.survey.ui + +import android.webkit.WebView +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView + +@Composable +fun HtmlText(text: String, modifier: Modifier = Modifier) { + val styledContent = HtmlUtils.applyStyle(text = text) + AndroidView( + modifier = modifier, + factory = { context -> + WebView(context).apply { + setBackgroundColor(android.graphics.Color.TRANSPARENT) + loadDataWithBaseURL( + null, + styledContent, + "text/html", + "UTF-8", + null + ) + } + } + ) +} + +object HtmlUtils { + private val htmlRegex = + "<([a-zA-Z0-9]+)(?:\\s+[^>]*)?>.*?|<([a-zA-Z0-9]+)(?:\\s+[^>]*)?/?>".toRegex() + + fun isHtml(text: String): Boolean { + return htmlRegex.containsMatchIn(text) + } + + /** + * TODO: Apply theme colors, font family and correct sizes + */ + @Composable + fun applyStyle(text: String) = """ + + + + + + + + $text + + + """.trimIndent() +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/QuestionButton.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/QuestionButton.kt new file mode 100644 index 000000000..d3f03165a --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/QuestionButton.kt @@ -0,0 +1,23 @@ +package edu.stanford.bdh.heartbeat.app.survey.ui + +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import edu.stanford.spezi.core.design.component.AsyncTextButton + +data class QuestionButton( + private val title: String, + private val enabled: Boolean, + private val onClick: () -> Unit, +) : SurveyItem { + @Composable + override fun Content(modifier: Modifier) { + AsyncTextButton( + text = title, + onClick = onClick, + enabled = enabled, + modifier = modifier.height(44.dp), + ) + } +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/QuestionFieldLabel.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/QuestionFieldLabel.kt new file mode 100644 index 000000000..6d4fb57c0 --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/QuestionFieldLabel.kt @@ -0,0 +1,22 @@ +package edu.stanford.bdh.heartbeat.app.survey.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import edu.stanford.spezi.core.design.theme.Spacings +import edu.stanford.spezi.core.design.theme.TextStyles + +data class QuestionFieldLabel( + private val label: String, +) : SurveyItem { + + @Composable + override fun Content(modifier: Modifier) { + Text( + text = label, + modifier = modifier.padding(bottom = Spacings.medium), + style = TextStyles.titleMedium + ) + } +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/QuestionNumberInfo.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/QuestionNumberInfo.kt new file mode 100644 index 000000000..9841135fe --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/QuestionNumberInfo.kt @@ -0,0 +1,25 @@ +package edu.stanford.bdh.heartbeat.app.survey.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import edu.stanford.spezi.core.design.theme.Colors +import edu.stanford.spezi.core.design.theme.Spacings +import edu.stanford.spezi.core.design.theme.TextStyles + +data class QuestionNumberInfo( + private val current: Int, + private val total: Int, +) : SurveyItem { + + @Composable + override fun Content(modifier: Modifier) { + Text( + text = "Question $current of $total", + modifier = Modifier.padding(bottom = Spacings.large), + style = TextStyles.bodyMedium, + color = Colors.tertiary + ) + } +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyCard.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyCard.kt new file mode 100644 index 000000000..80515bad4 --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyCard.kt @@ -0,0 +1,21 @@ +package edu.stanford.bdh.heartbeat.app.survey.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import edu.stanford.spezi.core.design.theme.Spacings + +@Composable +fun SurveyCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Card(modifier = modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxSize().padding(Spacings.medium), content = content) + } +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyItem.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyItem.kt new file mode 100644 index 000000000..65412c4ce --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyItem.kt @@ -0,0 +1,28 @@ +package edu.stanford.bdh.heartbeat.app.survey.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import edu.stanford.spezi.core.design.theme.Spacings +import edu.stanford.spezi.core.design.theme.SpeziTheme + +interface SurveyItem { + + @Composable + fun Content(modifier: Modifier) +} + +@Composable +fun SurveyItemPreview(fillScreenSize: Boolean = true, content: @Composable () -> Unit) { + SpeziTheme(isPreview = fillScreenSize) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(Spacings.medium) + ) { + item { content() } + } + } +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyProgress.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyProgress.kt new file mode 100644 index 000000000..6d4b8a83c --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyProgress.kt @@ -0,0 +1,48 @@ +package edu.stanford.bdh.heartbeat.app.survey.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import edu.stanford.spezi.core.design.theme.Colors +import edu.stanford.spezi.core.design.theme.ThemePreviews + +data class SurveyProgress( + private val value: Float, +) : SurveyItem { + + @Composable + override fun Content(modifier: Modifier) { + val coercedValue = remember(value) { value.coerceIn(0f, 1f) } + Box( + modifier = modifier + .fillMaxWidth() + .height(12.dp) + .background(Colors.black20, CircleShape) + ) { + Box( + Modifier + .fillMaxHeight() + .fillMaxWidth(fraction = coercedValue) + .background(Colors.cardinalRedLight, CircleShape) + ) + } + } +} + +@ThemePreviews +@Composable +private fun Previews() { + val progress = SurveyProgress( + value = 0.3f, + ) + SurveyItemPreview { + progress.Content(Modifier) + } +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyQuestionState.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyQuestionState.kt new file mode 100644 index 000000000..3f4061b4b --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyQuestionState.kt @@ -0,0 +1,106 @@ +package edu.stanford.bdh.heartbeat.app.survey.ui + +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.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import edu.stanford.bdh.heartbeat.app.survey.ui.fields.FormFieldItem +import edu.stanford.spezi.core.design.component.RectangleShimmerEffect +import edu.stanford.spezi.core.design.component.VerticalSpacer +import edu.stanford.spezi.core.design.component.height +import edu.stanford.spezi.core.design.theme.Spacings +import edu.stanford.spezi.core.design.theme.TextStyles +import edu.stanford.spezi.core.design.theme.ThemePreviews + +interface SurveyQuestionState : SurveyItem { + + object Loading : SurveyQuestionState { + + @Composable + override fun Content(modifier: Modifier) { + LazyColumn { + items(PLACEHOLDERS_COUNT) { + SurveyCard( + modifier = Modifier.padding( + top = if (it == 0) Spacings.medium else Spacings.small, + bottom = if (it == PLACEHOLDERS_COUNT - 1) Spacings.medium else Spacings.small, + ) + ) { + RectangleShimmerEffect( + modifier = Modifier + .fillMaxWidth() + .height(textStyle = TextStyles.titleLarge) + ) + + VerticalSpacer(height = Spacings.medium) + + RectangleShimmerEffect( + modifier = Modifier + .fillMaxWidth() + .height(textStyle = TextStyles.titleLarge) + ) + + VerticalSpacer(height = Spacings.medium) + + RectangleShimmerEffect( + modifier = Modifier + .fillMaxWidth(fraction = 0.4f) + .height(textStyle = TextStyles.titleSmall) + ) + } + } + } + } + } + + data class Question( + val progress: SurveyProgress, + val title: SurveyQuestionTitle, + val secondaryTitle: SurveyQuestionTitle?, + val fields: List, + val backButton: QuestionButton?, + val continueButton: QuestionButton, + val onDisplayed: () -> Unit, + ) : SurveyQuestionState { + + @Composable + override fun Content(modifier: Modifier) { + LaunchedEffect(title) { onDisplayed() } + Column(modifier = modifier) { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(Spacings.medium) + ) { + item { progress.Content(Modifier.padding(top = Spacings.medium)) } + item { title.Content(Modifier) } + secondaryTitle?.let { item { it.Content(Modifier) } } + items(fields) { field -> field.Content(Modifier) } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = Spacings.medium), + horizontalArrangement = Arrangement.spacedBy(Spacings.medium) + ) { + backButton?.Content(Modifier.weight(1f)) + continueButton.Content(Modifier.weight(1f)) + } + } + } + } +} + +private const val PLACEHOLDERS_COUNT = 10 + +@ThemePreviews +@Composable +private fun Previews() { + SurveyItemPreview { + SurveyQuestionState.Loading.Content(Modifier) + } +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyQuestionTitle.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyQuestionTitle.kt new file mode 100644 index 000000000..f607ddb1a --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyQuestionTitle.kt @@ -0,0 +1,41 @@ +package edu.stanford.bdh.heartbeat.app.survey.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import edu.stanford.spezi.core.design.theme.ThemePreviews + +data class SurveyQuestionTitle( + private val content: String, +) : SurveyItem { + + @Composable + override fun Content(modifier: Modifier) { + SurveyCard { + val isHtml = remember(content) { HtmlUtils.isHtml(content) } + if (isHtml) { + HtmlText(text = content, modifier = modifier) + } else { + Text( + modifier = modifier.fillMaxWidth(), + text = content.trim(), + textAlign = TextAlign.Start + ) + } + } + } +} + +@ThemePreviews +@Composable +fun ProgressPreview() { + val lipsum = """ + lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua ut enim ad minim veniam quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt in culpa qui officia deserunt mollit anim id est laborum + """.trimIndent() + SurveyItemPreview { + SurveyQuestionTitle(lipsum).Content(Modifier) + } +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyUiState.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyUiState.kt new file mode 100644 index 000000000..6b243c029 --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/SurveyUiState.kt @@ -0,0 +1,23 @@ +package edu.stanford.bdh.heartbeat.app.survey.ui + +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.Modifier +import edu.stanford.spezi.core.design.component.CommonScaffold +import edu.stanford.spezi.core.design.theme.Spacings + +data class SurveyUiState( + val pageTitle: String, + val questionState: SurveyQuestionState, +) : SurveyItem { + @Composable + override fun Content(modifier: Modifier) { + CommonScaffold(modifier = modifier, title = pageTitle) { + Column(modifier = Modifier.padding(horizontal = Spacings.medium)) { + questionState.Content(modifier = Modifier.fillMaxSize()) + } + } + } +} diff --git a/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/fields/ChoicesFormFieldItem.kt b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/fields/ChoicesFormFieldItem.kt new file mode 100644 index 000000000..6dfbd1dca --- /dev/null +++ b/heartbeat-app/src/main/kotlin/edu/stanford/bdh/heartbeat/app/survey/ui/fields/ChoicesFormFieldItem.kt @@ -0,0 +1,184 @@ +package edu.stanford.bdh.heartbeat.app.survey.ui.fields + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.RadioButton +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.alpha +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import edu.stanford.bdh.heartbeat.app.survey.ui.QuestionFieldLabel +import edu.stanford.bdh.heartbeat.app.survey.ui.QuestionNumberInfo +import edu.stanford.bdh.heartbeat.app.survey.ui.SurveyCard +import edu.stanford.bdh.heartbeat.app.survey.ui.SurveyItemPreview +import edu.stanford.spezi.core.design.theme.Colors +import edu.stanford.spezi.core.design.theme.Spacings +import edu.stanford.spezi.core.design.theme.TextStyles +import edu.stanford.spezi.core.design.theme.ThemePreviews + +data class ChoicesFormFieldItem( + override val fieldId: String, + val style: Style, + val info: QuestionNumberInfo, + val fieldLabel: QuestionFieldLabel?, + val options: List