From 9f61be258a88d9a748601d94919da4dcd92c643d Mon Sep 17 00:00:00 2001 From: "Ian G. Clifton" Date: Tue, 27 Dec 2022 15:50:33 -0800 Subject: [PATCH 1/7] [Jetsurvey] Simplification of survey screens This eliminates the survey repository and related data models in favor of simplifying the code base. Each of the different question types has a generic composable such as MultipleChoiceQuestion, which takes all of the relevant strings/resources to generate the UI. The actual survey data is baked into a specific composable in Survey.kt, such as FreeTimeQuestion, instead of coming from a data model. The SurveyViewModel is now much more explicit with methods for each question such as onFreeTimeResponse. In a production app, you'd likely want this to be more flexible to handle server-side questions, but this code pattern makes the sample easier to skim. Every response is maintained in SurveyViewModel as a separate state. This increases verbosity in favor of better readability. This also appears to fix the issue of photos not working on API 21, though I'm not sure what specific change fixed it. I verified on a Nexus 4 running Android 5.1.1. Note: This does not animate between the final survey question and the survey results. When the work to move the app to Compose Navigation is done, the results will be a separate destination that is navigated to and animated that way. This fixes #1054. --- Jetsurvey/app/src/main/AndroidManifest.xml | 3 +- .../compose/jetsurvey/survey/Survey.kt | 161 ++++++----- .../jetsurvey/survey/SurveyFragment.kt | 183 +++++++++---- .../jetsurvey/survey/SurveyQuestions.kt | 218 +++------------ .../jetsurvey/survey/SurveyRepository.kt | 127 --------- .../compose/jetsurvey/survey/SurveyScreen.kt | 179 +++++-------- .../compose/jetsurvey/survey/SurveyState.kt | 49 ---- .../jetsurvey/survey/SurveyViewModel.kt | 209 +++++++++------ .../survey/question/ActionQuestion.kt | 52 ---- .../survey/question/ChoiceQuestion.kt | 250 ------------------ .../jetsurvey/survey/question/DateQuestion.kt | 93 ++++--- .../survey/question/MultipleChoiceQuestion.kt | 120 +++++++++ .../survey/question/PhotoQuestion.kt | 104 +++++--- .../survey/question/SingleChoiceQuestion.kt | 153 +++++++++++ .../survey/question/SliderQuestion.kt | 53 ++-- .../app/src/main/res/navigation/nav_graph.xml | 1 - Jetsurvey/app/src/main/res/values/strings.xml | 23 +- .../jetsurvey/survey/SurveyViewModelTest.kt | 50 ++-- Jetsurvey/gradle/libs.versions.toml | 3 +- 19 files changed, 893 insertions(+), 1138 deletions(-) delete mode 100644 Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyRepository.kt delete mode 100644 Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyState.kt delete mode 100644 Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/ActionQuestion.kt delete mode 100644 Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/ChoiceQuestion.kt create mode 100644 Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/MultipleChoiceQuestion.kt create mode 100644 Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/SingleChoiceQuestion.kt diff --git a/Jetsurvey/app/src/main/AndroidManifest.xml b/Jetsurvey/app/src/main/AndroidManifest.xml index 7a14088bc7..34dff1ce34 100644 --- a/Jetsurvey/app/src/main/AndroidManifest.xml +++ b/Jetsurvey/app/src/main/AndroidManifest.xml @@ -30,8 +30,7 @@ + android:exported="true"> diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/Survey.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/Survey.kt index 46893d5a5c..ed4940aafc 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/Survey.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/Survey.kt @@ -17,83 +17,102 @@ package com.example.compose.jetsurvey.survey import android.net.Uri -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.compose.jetsurvey.R +import com.example.compose.jetsurvey.survey.question.DateQuestion +import com.example.compose.jetsurvey.survey.question.MultipleChoiceQuestion +import com.example.compose.jetsurvey.survey.question.PhotoQuestion +import com.example.compose.jetsurvey.survey.question.SingleChoiceQuestion +import com.example.compose.jetsurvey.survey.question.SliderQuestion +import com.example.compose.jetsurvey.survey.question.Superhero -data class SurveyResult( - val library: String, - @StringRes val result: Int, - @StringRes val description: Int -) - -data class Survey( - @StringRes val title: Int, - val questions: List -) - -data class Question( - val id: Int, - @StringRes val questionText: Int, - val answer: PossibleAnswer, - @StringRes val description: Int? = null, - val permissionsRequired: List = emptyList(), - @StringRes val permissionsRationaleText: Int? = null -) - -/** - * Type of supported actions for a survey - */ -enum class SurveyActionType { PICK_DATE, TAKE_PHOTO, SELECT_CONTACT } - -sealed class SurveyActionResult { - data class Date(val dateMillis: Long) : SurveyActionResult() - data class Photo(val uri: Uri) : SurveyActionResult() - data class Contact(val contact: String) : SurveyActionResult() +@Composable +fun FreeTimeQuestion( + modifier: Modifier = Modifier, + selectedAnswers: List, + onOptionSelected: (selected: Boolean, answer: Int) -> Unit, +) { + MultipleChoiceQuestion( + modifier = modifier, + titleResourceId = R.string.in_my_free_time, + directionResourceId = R.string.select_all, + possibleAnswers = listOf( + R.string.read, + R.string.work_out, + R.string.draw, + R.string.play_games, + R.string.dance, + R.string.watch_movies, + ), + selectedAnswers = selectedAnswers, + onOptionSelected = onOptionSelected, + ) } -data class AnswerOption(@StringRes val textRes: Int, @DrawableRes val iconRes: Int? = null) - -sealed class PossibleAnswer { - data class SingleChoice(val options: List) : PossibleAnswer() - data class MultipleChoice(val options: List) : PossibleAnswer() - data class Action( - @StringRes val label: Int, - val actionType: SurveyActionType - ) : PossibleAnswer() - - data class Slider( - val range: ClosedFloatingPointRange, - val steps: Int, - @StringRes val startText: Int, - @StringRes val endText: Int, - @StringRes val neutralText: Int, - val defaultValue: Float = 5.5f - ) : PossibleAnswer() +@Composable +fun SuperheroQuestion( + modifier: Modifier = Modifier, + selectedAnswer: Superhero?, + onOptionSelected: (Superhero) -> Unit, +) { + SingleChoiceQuestion( + modifier = modifier, + titleResourceId = R.string.pick_superhero, + directionResourceId = R.string.select_one, + possibleAnswers = listOf( + Superhero(R.string.spark, R.drawable.spark), + Superhero(R.string.lenz, R.drawable.lenz), + Superhero(R.string.bugchaos, R.drawable.bug_of_chaos), + Superhero(R.string.frag, R.drawable.frag), + ), + selectedAnswer = selectedAnswer, + onOptionSelected = onOptionSelected, + ) } -sealed class Answer { - object PermissionsDenied : Answer() - data class SingleChoice(@StringRes val answer: Int) : Answer() - data class MultipleChoice(val answersStringRes: Set) : - Answer() +@Composable +fun TakeawayQuestion( + modifier: Modifier = Modifier, + dateInMillis: Long?, + onClick: () -> Unit, +) { + DateQuestion( + modifier = modifier, + titleResourceId = R.string.takeaway, + directionResourceId = R.string.select_date, + dateInMillis = dateInMillis, + onClick = onClick, + ) +} - data class Action(val result: SurveyActionResult) : Answer() - data class Slider(val answerValue: Float) : Answer() +@Composable +fun FeelingAboutSelfiesQuestion( + modifier: Modifier = Modifier, + value: Float?, + onValueChange: (Float) -> Unit, +) { + SliderQuestion( + modifier = modifier, + titleResourceId = R.string.selfies, + value = value, + onValueChange = onValueChange, + startTextResource = R.string.strongly_dislike, + neutralTextResource = R.string.neutral, + endTextResource = R.string.strongly_like, + ) } -/** - * Add or remove an answer from the list of selected answers depending on whether the answer was - * selected or deselected. - */ -fun Answer.MultipleChoice.withAnswerSelected( - @StringRes answer: Int, - selected: Boolean -): Answer.MultipleChoice { - val newStringRes = answersStringRes.toMutableSet() - if (!selected) { - newStringRes.remove(answer) - } else { - newStringRes.add(answer) - } - return Answer.MultipleChoice(newStringRes) +@Composable +fun TakeSelfieQuestion( + modifier: Modifier = Modifier, + imageUri: Uri?, + onClick: () -> Unit, +) { + PhotoQuestion( + modifier = modifier, + titleResourceId = R.string.selfie_skills, + imageUri = imageUri, + onClick = onClick, + ) } diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt index 412f0b6285..5fe01234d3 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt @@ -16,20 +16,29 @@ package com.example.compose.jetsurvey.survey +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.compose.BackHandler import androidx.activity.result.contract.ActivityResultContracts.TakePicture import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.TweenSpec import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.animation.with +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import com.example.compose.jetsurvey.R @@ -42,10 +51,12 @@ class SurveyFragment : Fragment() { SurveyViewModelFactory(PhotoUriManager(requireContext().applicationContext)) } - private val takePicture = registerForActivityResult(TakePicture()) { photoSaved -> + private var selfieUri: Uri? = null + private val takeSelfie = registerForActivityResult(TakePicture()) { photoSaved -> if (photoSaved) { - viewModel.onImageSaved() + viewModel.onSelfieResponse(selfieUri!!) } + selfieUri = null } @OptIn(ExperimentalAnimationApi::class) @@ -62,40 +73,107 @@ class SurveyFragment : Fragment() { ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) + setContent { JetsurveyTheme { - val state = viewModel.uiState.observeAsState().value ?: return@JetsurveyTheme - AnimatedContent( - targetState = state, - transitionSpec = { - fadeIn() + slideIntoContainer( - towards = AnimatedContentScope - .SlideDirection.Up, - animationSpec = tween(ANIMATION_SLIDE_IN_DURATION) - ) with - fadeOut(animationSpec = tween(ANIMATION_FADE_OUT_DURATION)) + val isSurveyComplete by viewModel.isSurveyComplete.observeAsState(false) + + if (isSurveyComplete) { + // Note, results are hardcoded for this demo; in a complete app, you'd + // likely send the survey responses to a backend to determine the + // result and then pass them here. + SurveyResultScreen( + title = stringResource(R.string.survey_result_title), + subtitle = stringResource(R.string.survey_result_subtitle), + description = stringResource(R.string.survey_result_description), + onDonePressed = { + activity?.onBackPressedDispatcher?.onBackPressed() + } + ) + } else { + val surveyScreenData = viewModel.surveyScreenData.observeAsState().value + ?: return@JetsurveyTheme + val isNextEnabled = viewModel.isNextEnabled.observeAsState().value ?: false + var shouldInterceptBackPresses by remember { mutableStateOf(true) } + + BackHandler { + if (!shouldInterceptBackPresses || !viewModel.onBackPressed()) { + activity?.onBackPressedDispatcher?.onBackPressed() + } } - ) { targetState -> - // It's important to use targetState and not state, as its critical to ensure - // a successful lookup of all the incoming and outgoing content during - // content transform. - when (targetState) { - is SurveyState.Questions -> SurveyQuestionsScreen( - questions = targetState, - shouldAskPermissions = viewModel.askForPermissions, - onAction = { id, action -> handleSurveyAction(id, action) }, - onDoNotAskForPermissions = { viewModel.doNotAskForPermissions() }, - onDonePressed = { viewModel.computeResult(targetState) }, - onBackPressed = { - activity?.onBackPressedDispatcher?.onBackPressed() + + SurveyQuestionsScreen( + surveyScreenData = surveyScreenData, + isNextEnabled = isNextEnabled, + onClosePressed = { + shouldInterceptBackPresses = false + activity?.onBackPressedDispatcher?.onBackPressed() + }, + onPreviousPressed = { viewModel.onPreviousPressed() }, + onNextPressed = { viewModel.onNextPressed() }, + onDonePressed = { viewModel.onDonePressed() } + ) { paddingValues -> + + val modifier = Modifier.padding(paddingValues) + + AnimatedContent( + targetState = surveyScreenData, + transitionSpec = { + val animationSpec: TweenSpec = + tween(CONTENT_ANIMATION_DURATION) + val direction = getTransitionDirection( + initialIndex = initialState.questionIndex, + targetIndex = targetState.questionIndex, + ) + slideIntoContainer( + towards = direction, + animationSpec = animationSpec, + ) with slideOutOfContainer( + towards = direction, + animationSpec = animationSpec + ) } - ) - is SurveyState.Result -> SurveyResultScreen( - result = targetState, - onDonePressed = { - activity?.onBackPressedDispatcher?.onBackPressed() + ) { targetState -> + + when (targetState.surveyQuestion) { + SurveyQuestion.FREE_TIME -> { + FreeTimeQuestion( + modifier, + viewModel.freeTimeResponse, + ) { selected, answer -> + viewModel.onFreeTimeResponse(selected, answer) + } + } + + SurveyQuestion.SUPERHERO -> SuperheroQuestion( + modifier, + viewModel.superheroResponse, + ) { superhero -> + viewModel.onSuperheroResponse(superhero) + } + + SurveyQuestion.LAST_TAKEAWAY -> TakeawayQuestion( + modifier, + dateInMillis = viewModel.takeawayResponse, + onClick = { showTakeawayDatePicker() } + ) + + SurveyQuestion.FEELING_ABOUT_SELFIES -> + FeelingAboutSelfiesQuestion( + modifier = modifier, + value = viewModel.feelingAboutSelfiesResponse, + onValueChange = { feeling -> + viewModel.onFeelingAboutSelfiesResponse(feeling) + } + ) + + SurveyQuestion.TAKE_SELFIE -> TakeSelfieQuestion( + modifier = modifier, + imageUri = viewModel.selfieUriResponse, + onClick = { takeSelfie() } + ) } - ) + } } } } @@ -103,38 +181,43 @@ class SurveyFragment : Fragment() { } } - private fun handleSurveyAction(questionId: Int, actionType: SurveyActionType) { - when (actionType) { - SurveyActionType.PICK_DATE -> showDatePicker(questionId) - SurveyActionType.TAKE_PHOTO -> takeAPhoto() - SurveyActionType.SELECT_CONTACT -> selectContact(questionId) + @OptIn(ExperimentalAnimationApi::class) + private fun getTransitionDirection( + initialIndex: Int, + targetIndex: Int + ): AnimatedContentScope.SlideDirection { + return if (targetIndex > initialIndex) { + // Going forwards in the survey: Set the initial offset to start + // at the size of the content so it slides in from right to left, and + // slides out from the left of the screen to -fullWidth + AnimatedContentScope.SlideDirection.Left + } else { + // Going back to the previous question in the set, we do the same + // transition as above, but with different offsets - the inverse of + // above, negative fullWidth to enter, and fullWidth to exit. + AnimatedContentScope.SlideDirection.Right } } - private fun showDatePicker(questionId: Int) { - val date = viewModel.getCurrentDate(questionId = questionId) + private fun showTakeawayDatePicker() { + val date = viewModel.takeawayResponse val picker = MaterialDatePicker.Builder.datePicker() .setSelection(date) .build() picker.show(requireActivity().supportFragmentManager, picker.toString()) picker.addOnPositiveButtonClickListener { picker.selection?.let { selectedDate -> - viewModel.onDatePicked(questionId, selectedDate) + viewModel.onTakeawayResponse(selectedDate) } } } - private fun takeAPhoto() { - takePicture.launch(viewModel.getUriToSaveImage()) - } - - @Suppress("UNUSED_PARAMETER") - private fun selectContact(questionId: Int) { - // TODO: unsupported for now + private fun takeSelfie() { + selfieUri = viewModel.getUriToSaveImage() + takeSelfie.launch(selfieUri) } companion object { - private const val ANIMATION_SLIDE_IN_DURATION = 600 - private const val ANIMATION_FADE_OUT_DURATION = 200 + private const val CONTENT_ANIMATION_DURATION = 500 } } diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt index edf5fbab55..ebd21f866d 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt @@ -19,216 +19,72 @@ package com.example.compose.jetsurvey.survey import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.survey.question.ActionQuestion -import com.example.compose.jetsurvey.survey.question.MultipleChoiceQuestion -import com.example.compose.jetsurvey.survey.question.SingleChoiceQuestion -import com.example.compose.jetsurvey.survey.question.SliderQuestion -import com.example.compose.jetsurvey.theme.JetsurveyTheme import com.example.compose.jetsurvey.theme.slightlyDeemphasizedAlpha import com.example.compose.jetsurvey.theme.stronglyDeemphasizedAlpha -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.MultiplePermissionsState -import com.google.accompanist.permissions.rememberMultiplePermissionsState - -@OptIn(ExperimentalPermissionsApi::class) -@Composable -fun Question( - question: Question, - answer: Answer<*>?, - shouldAskPermissions: Boolean, - onAnswer: (Answer<*>) -> Unit, - onAction: (Int, SurveyActionType) -> Unit, - onDoNotAskForPermissions: () -> Unit, - modifier: Modifier = Modifier -) { - if (question.permissionsRequired.isEmpty()) { - QuestionContent(question, answer, onAnswer, onAction, modifier) - } else { - val multiplePermissionsState = rememberMultiplePermissionsState( - question.permissionsRequired - ) - - if (multiplePermissionsState.allPermissionsGranted) { - QuestionContent(question, answer, onAnswer, onAction, modifier) - } else { - PermissionsRationale( - question, - multiplePermissionsState, - onDoNotAskForPermissions, - modifier.padding(horizontal = 20.dp) - ) - } - - // If we cannot ask for permissions, inform the caller that can move to the next question - if (!shouldAskPermissions) { - LaunchedEffect(true) { - onAnswer(Answer.PermissionsDenied) - } - } - } -} - -@OptIn(ExperimentalPermissionsApi::class) -@Composable -private fun PermissionsRationale( - question: Question, - multiplePermissionsState: MultiplePermissionsState, - onDoNotAskForPermissions: () -> Unit, - modifier: Modifier = Modifier -) { - Column(modifier) { - Spacer(modifier = Modifier.height(32.dp)) - QuestionTitle(question.questionText) - Spacer(modifier = Modifier.height(32.dp)) - val rationaleId = - question.permissionsRationaleText ?: R.string.permissions_rationale - Text(stringResource(id = rationaleId)) - Spacer(modifier = Modifier.height(16.dp)) - OutlinedButton( - onClick = { - multiplePermissionsState.launchMultiplePermissionRequest() - } - ) { - Text(stringResource(R.string.request_permissions)) - } - Spacer(modifier = Modifier.height(8.dp)) - OutlinedButton(onClick = onDoNotAskForPermissions) { - Text(stringResource(R.string.do_not_ask_permissions)) - } - } -} +/** + * Creates a Column that is vertically scrollable and contains the title, the directions (if a + * string resource is passed), and the content + */ @Composable -private fun QuestionContent( - question: Question, - answer: Answer<*>?, - onAnswer: (Answer<*>) -> Unit, - onAction: (Int, SurveyActionType) -> Unit, - modifier: Modifier = Modifier +fun QuestionWrapper( + modifier: Modifier = Modifier, + @StringRes titleResourceId: Int, + @StringRes directionResourceId: Int? = null, + content: @Composable () -> Unit, ) { - LazyColumn( - modifier = modifier, - contentPadding = PaddingValues(start = 20.dp, end = 20.dp) + Column( + modifier = modifier + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) ) { - item { - Spacer(modifier = Modifier.height(32.dp)) - QuestionTitle(question.questionText) - Spacer(modifier = Modifier.height(24.dp)) - if (question.description != null) { - Text( - text = stringResource(id = question.description), - color = MaterialTheme.colorScheme.onSurface - .copy(alpha = stronglyDeemphasizedAlpha), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier - .fillParentMaxWidth() - .padding(bottom = 18.dp, start = 8.dp, end = 8.dp) - ) - } - when (question.answer) { - is PossibleAnswer.SingleChoice -> SingleChoiceQuestion( - options = question.answer.options, - answer = answer as Answer.SingleChoice?, - onAnswerSelected = { answer -> onAnswer(Answer.SingleChoice(answer)) }, - modifier = Modifier.fillMaxWidth() - ) - is PossibleAnswer.MultipleChoice -> MultipleChoiceQuestion( - possibleAnswer = question.answer, - answer = answer as Answer.MultipleChoice?, - onAnswerSelected = { newAnswer, selected -> - // create the answer if it doesn't exist or - // update it based on the user's selection - if (answer == null) { - onAnswer(Answer.MultipleChoice(setOf(newAnswer))) - } else { - onAnswer(answer.withAnswerSelected(newAnswer, selected)) - } - }, - modifier = Modifier.fillMaxWidth() - ) - is PossibleAnswer.Action -> ActionQuestion( - questionId = question.id, - possibleAnswer = question.answer, - answer = answer as Answer.Action?, - onAction = onAction, - modifier = Modifier.fillParentMaxWidth() - ) - is PossibleAnswer.Slider -> SliderQuestion( - possibleAnswer = question.answer, - answer = answer as Answer.Slider?, - onAnswerSelected = { onAnswer(Answer.Slider(it)) }, - modifier = Modifier.fillParentMaxWidth() - ) - } + Spacer(Modifier.height(32.dp)) + QuestionTitle(titleResourceId) + directionResourceId?.let { + QuestionDirections(it) } + + content() } } @Composable -private fun QuestionTitle(@StringRes title: Int) { - Row( +fun QuestionTitle(@StringRes title: Int) { + Text( + text = stringResource(id = title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = slightlyDeemphasizedAlpha), modifier = Modifier .fillMaxWidth() .background( color = MaterialTheme.colorScheme.inverseOnSurface, shape = MaterialTheme.shapes.small ) - ) { - Text( - text = stringResource(id = title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = slightlyDeemphasizedAlpha), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 24.dp, horizontal = 16.dp) - ) - } + .padding(vertical = 24.dp, horizontal = 16.dp) + ) } -@Preview @Composable -fun QuestionPreview() { - val question = Question( - id = 2, - questionText = R.string.pick_superhero, - answer = PossibleAnswer.SingleChoice( - options = listOf( - AnswerOption(R.string.spark), - AnswerOption(R.string.lenz), - AnswerOption(R.string.bugchaos), - AnswerOption(R.string.frag) - ) - ), - description = R.string.select_one +fun QuestionDirections(@StringRes directionsResourceId: Int) { + Text( + text = stringResource(id = directionsResourceId), + color = MaterialTheme.colorScheme.onSurface + .copy(alpha = stronglyDeemphasizedAlpha), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 18.dp, horizontal = 8.dp) ) - JetsurveyTheme { - Surface { - Question( - question = question, - shouldAskPermissions = true, - answer = null, - onAnswer = {}, - onAction = { _, _ -> }, - onDoNotAskForPermissions = {} - ) - } - } } diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyRepository.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyRepository.kt deleted file mode 100644 index 12bd59f436..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyRepository.kt +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import android.os.Build -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.survey.PossibleAnswer.Action -import com.example.compose.jetsurvey.survey.SurveyActionType.PICK_DATE -import com.example.compose.jetsurvey.survey.SurveyActionType.TAKE_PHOTO - -// Static data of questions -private val jetpackQuestions = mutableListOf( - Question( - id = 1, - questionText = R.string.in_my_free_time, - answer = PossibleAnswer.MultipleChoice( - options = listOf( - AnswerOption(R.string.read), - AnswerOption(R.string.work_out), - AnswerOption(R.string.draw), - AnswerOption(R.string.play_games), - AnswerOption(R.string.dance), - AnswerOption(R.string.watch_movies) - ) - ), - description = R.string.select_all - ), - Question( - id = 2, - questionText = R.string.pick_superhero, - answer = PossibleAnswer.SingleChoice( - options = listOf( - AnswerOption(R.string.spark, R.drawable.spark), - AnswerOption(R.string.lenz, R.drawable.lenz), - AnswerOption(R.string.bugchaos, R.drawable.bug_of_chaos), - AnswerOption(R.string.frag, R.drawable.frag) - ) - ), - description = R.string.select_one - ), - Question( - id = 7, - questionText = R.string.favourite_movie, - answer = PossibleAnswer.SingleChoice( - listOf( - AnswerOption(R.string.star_trek), - AnswerOption(R.string.social_network), - AnswerOption(R.string.back_to_future), - AnswerOption(R.string.outbreak) - ) - ), - description = R.string.select_one - ), - Question( - id = 3, - questionText = R.string.takeaway, - answer = Action(label = R.string.pick_date, actionType = PICK_DATE), - description = R.string.select_date - ), - Question( - id = 4, - questionText = R.string.selfies, - answer = PossibleAnswer.Slider( - range = 1f..10f, - steps = 3, - startText = R.string.strongly_dislike, - endText = R.string.strongly_like, - neutralText = R.string.neutral - ) - ), -).apply { - // TODO: FIX! After taking the selfie, the picture doesn't appear in API 22 and lower. - if (Build.VERSION.SDK_INT >= 23) { - add( - Question( - id = 975, - questionText = R.string.selfie_skills, - answer = Action(label = R.string.add_photo, actionType = TAKE_PHOTO), - permissionsRequired = - when (Build.VERSION.SDK_INT) { - in 23..28 -> listOf(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) - else -> emptyList() - }, - permissionsRationaleText = R.string.selfie_permissions - ) - ) - } -}.toList() - -private val jetpackSurvey = Survey( - title = R.string.which_jetpack_library, - questions = jetpackQuestions -) - -object JetpackSurveyRepository : SurveyRepository { - - override fun getSurvey() = jetpackSurvey - - @Suppress("UNUSED_PARAMETER") - override fun getSurveyResult(answers: List>): SurveyResult { - return SurveyResult( - library = "Compose", - result = R.string.survey_result, - description = R.string.survey_result_description - ) - } -} - -interface SurveyRepository { - fun getSurvey(): Survey - - fun getSurveyResult(answers: List>): SurveyResult -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyScreen.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyScreen.kt index cb128780f1..093676c366 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyScreen.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyScreen.kt @@ -16,15 +16,9 @@ package com.example.compose.jetsurvey.survey -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.TweenSpec import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.animation.with -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -36,6 +30,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Button +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -48,92 +43,43 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.example.compose.jetsurvey.R import com.example.compose.jetsurvey.theme.stronglyDeemphasizedAlpha import com.example.compose.jetsurvey.util.supportWideScreen -private const val CONTENT_ANIMATION_DURATION = 500 - -@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class) -// AnimatedContent is experimental, Scaffold is experimental in m3 +@OptIn(ExperimentalMaterial3Api::class) +// Scaffold is experimental in m3 @Composable fun SurveyQuestionsScreen( - questions: SurveyState.Questions, - shouldAskPermissions: Boolean, - onDoNotAskForPermissions: () -> Unit, - onAction: (Int, SurveyActionType) -> Unit, + surveyScreenData: SurveyScreenData, + isNextEnabled: Boolean, + onClosePressed: () -> Unit, + onPreviousPressed: () -> Unit, + onNextPressed: () -> Unit, onDonePressed: () -> Unit, - onBackPressed: () -> Unit + content: @Composable (PaddingValues) -> Unit, ) { - val questionState = remember(questions.currentQuestionIndex) { - questions.questionsState[questions.currentQuestionIndex] - } Surface(modifier = Modifier.supportWideScreen()) { Scaffold( topBar = { SurveyTopAppBar( - questionIndex = questionState.questionIndex, - totalQuestionsCount = questionState.totalQuestionsCount, - onBackPressed = onBackPressed + questionIndex = surveyScreenData.questionIndex, + totalQuestionsCount = surveyScreenData.questionCount, + onClosePressed = onClosePressed, ) }, - content = { innerPadding -> - AnimatedContent( - targetState = questionState, - transitionSpec = { - val animationSpec: TweenSpec = tween(CONTENT_ANIMATION_DURATION) - val direction = - if (targetState.questionIndex > initialState.questionIndex) { - // Going forwards in the survey: Set the initial offset to start - // at the size of the content so it slides in from right to left, and - // slides out from the left of the screen to -fullWidth - AnimatedContentScope.SlideDirection.Left - } else { - // Going back to the previous question in the set, we do the same - // transition as above, but with different offsets - the inverse of - // above, negative fullWidth to enter, and fullWidth to exit. - AnimatedContentScope.SlideDirection.Right - } - slideIntoContainer( - towards = direction, - animationSpec = animationSpec - ) with - slideOutOfContainer( - towards = direction, - animationSpec = animationSpec - ) - } - ) { targetState -> - Question( - question = targetState.question, - answer = targetState.answer, - shouldAskPermissions = shouldAskPermissions, - onAnswer = { - if (it !is Answer.PermissionsDenied) { - targetState.answer = it - } - targetState.enableNext = true - }, - onAction = onAction, - onDoNotAskForPermissions = onDoNotAskForPermissions, - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) - } - }, + content = content, bottomBar = { SurveyBottomBar( - questionState = questionState, - onPreviousPressed = { questions.currentQuestionIndex-- }, - onNextPressed = { questions.currentQuestionIndex++ }, + shouldShowPreviousButton = surveyScreenData.shouldShowPreviousButton, + shouldShowDoneButton = surveyScreenData.shouldShowDoneButton, + isNextButtonEnabled = isNextEnabled, + onPreviousPressed = onPreviousPressed, + onNextPressed = onNextPressed, onDonePressed = onDonePressed ) } @@ -144,18 +90,25 @@ fun SurveyQuestionsScreen( @OptIn(ExperimentalMaterial3Api::class) // Scaffold is experimental in m3 @Composable fun SurveyResultScreen( - result: SurveyState.Result, + title: String, + subtitle: String, + description: String, onDonePressed: () -> Unit ) { Surface(modifier = Modifier.supportWideScreen()) { Scaffold( content = { innerPadding -> val modifier = Modifier.padding(innerPadding) - SurveyResult(result = result, modifier = modifier) + SurveyResult( + title = title, + subtitle = subtitle, + description = description, + modifier = modifier + ) }, bottomBar = { OutlinedButton( - onClick = { onDonePressed() }, + onClick = onDonePressed, modifier = Modifier .fillMaxWidth() .padding(horizontal = 20.dp, vertical = 24.dp) @@ -168,25 +121,27 @@ fun SurveyResultScreen( } @Composable -private fun SurveyResult(result: SurveyState.Result, modifier: Modifier = Modifier) { +private fun SurveyResult( + title: String, + subtitle: String, + description: String, + modifier: Modifier = Modifier +) { LazyColumn(modifier = modifier.fillMaxSize()) { item { Spacer(modifier = Modifier.height(44.dp)) Text( - text = result.surveyResult.library, + text = title, style = MaterialTheme.typography.displaySmall, modifier = Modifier.padding(horizontal = 20.dp) ) Text( - text = stringResource( - result.surveyResult.result, - result.surveyResult.library - ), + text = subtitle, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(20.dp) ) Text( - text = stringResource(result.surveyResult.description), + text = description, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(horizontal = 20.dp) ) @@ -214,36 +169,36 @@ private fun TopAppBarTitle( } } +@OptIn(ExperimentalMaterial3Api::class) // CenterAlignedTopAppBar is experimental in m3 @Composable -private fun SurveyTopAppBar( +fun SurveyTopAppBar( questionIndex: Int, totalQuestionsCount: Int, - onBackPressed: () -> Unit + onClosePressed: () -> Unit ) { Column(modifier = Modifier.fillMaxWidth()) { - Box(modifier = Modifier.fillMaxWidth()) { - TopAppBarTitle( - questionIndex = questionIndex, - totalQuestionsCount = totalQuestionsCount, - modifier = Modifier - .padding(vertical = 20.dp) - .align(Alignment.Center) - ) - IconButton( - onClick = onBackPressed, - modifier = Modifier - .padding(horizontal = 20.dp, vertical = 20.dp) - .fillMaxWidth() - ) { - Icon( - Icons.Filled.Close, - contentDescription = stringResource(id = R.string.close), - modifier = Modifier.align(Alignment.CenterEnd), - tint = MaterialTheme.colorScheme.onSurface.copy(stronglyDeemphasizedAlpha) + CenterAlignedTopAppBar( + title = { + TopAppBarTitle( + questionIndex = questionIndex, + totalQuestionsCount = totalQuestionsCount, ) + }, + actions = { + IconButton( + onClick = onClosePressed, + modifier = Modifier.padding(4.dp) + ) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(id = R.string.close), + tint = MaterialTheme.colorScheme.onSurface.copy(stronglyDeemphasizedAlpha) + ) + } } - } + ) + val animatedProgress by animateFloatAsState( targetValue = (questionIndex + 1) / totalQuestionsCount.toFloat(), animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec @@ -259,8 +214,10 @@ private fun SurveyTopAppBar( } @Composable -private fun SurveyBottomBar( - questionState: QuestionState, +fun SurveyBottomBar( + shouldShowPreviousButton: Boolean, + shouldShowDoneButton: Boolean, + isNextButtonEnabled: Boolean, onPreviousPressed: () -> Unit, onNextPressed: () -> Unit, onDonePressed: () -> Unit @@ -275,7 +232,7 @@ private fun SurveyBottomBar( .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 20.dp) ) { - if (questionState.showPrevious) { + if (shouldShowPreviousButton) { OutlinedButton( modifier = Modifier .weight(1f) @@ -286,13 +243,13 @@ private fun SurveyBottomBar( } Spacer(modifier = Modifier.width(16.dp)) } - if (questionState.showDone) { + if (shouldShowDoneButton) { Button( modifier = Modifier .weight(1f) .height(48.dp), onClick = onDonePressed, - enabled = questionState.enableNext + enabled = isNextButtonEnabled, ) { Text(text = stringResource(id = R.string.done)) } @@ -302,7 +259,7 @@ private fun SurveyBottomBar( .weight(1f) .height(48.dp), onClick = onNextPressed, - enabled = questionState.enableNext + enabled = isNextButtonEnabled, ) { Text(text = stringResource(id = R.string.next)) } diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyState.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyState.kt deleted file mode 100644 index b9b92ba40a..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyState.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey - -import androidx.annotation.StringRes -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue - -@Stable -class QuestionState( - val question: Question, - val questionIndex: Int, - val totalQuestionsCount: Int, - val showPrevious: Boolean, - val showDone: Boolean -) { - var enableNext by mutableStateOf(false) - var answer by mutableStateOf?>(null) -} - -sealed class SurveyState { - data class Questions( - @StringRes val surveyTitle: Int, - val questionsState: List - ) : SurveyState() { - var currentQuestionIndex by mutableStateOf(0) - } - - data class Result( - @StringRes val surveyTitle: Int, - val surveyResult: SurveyResult - ) : SurveyState() -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyViewModel.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyViewModel.kt index 7c7b18485f..ae76dbeaaa 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyViewModel.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyViewModel.kt @@ -17,125 +17,156 @@ package com.example.compose.jetsurvey.survey import android.net.Uri -import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.example.compose.jetsurvey.util.getDefaultDateInMillis -import kotlinx.coroutines.launch +import com.example.compose.jetsurvey.survey.question.Superhero const val simpleDateFormatPattern = "EEE, MMM d" class SurveyViewModel( - private val surveyRepository: SurveyRepository, private val photoUriManager: PhotoUriManager ) : ViewModel() { - private val _uiState = MutableLiveData() - val uiState: LiveData - get() = _uiState + private val questionOrder: List = listOf( + SurveyQuestion.FREE_TIME, + SurveyQuestion.SUPERHERO, + SurveyQuestion.LAST_TAKEAWAY, + SurveyQuestion.FEELING_ABOUT_SELFIES, + SurveyQuestion.TAKE_SELFIE + ) - var askForPermissions by mutableStateOf(true) - private set - - private lateinit var surveyInitialState: SurveyState + private var questionIndex = 0 // Uri used to save photos taken with the camera private var uri: Uri? = null + // ----- Responses exposed as State ----- + + private val _freeTimeResponse = mutableStateListOf() + val freeTimeResponse: List + get() = _freeTimeResponse + + private val _superheroResponse = mutableStateOf(null) + val superheroResponse: Superhero? + get() = _superheroResponse.value + + private val _takeawayResponse = mutableStateOf(null) + val takeawayResponse: Long? + get() = _takeawayResponse.value + + private val _feelingAboutSelfiesResponse = mutableStateOf(null) + val feelingAboutSelfiesResponse: Float? + get() = _feelingAboutSelfiesResponse.value + + private val _selfieUriResponse = mutableStateOf(null) + val selfieUriResponse: Uri? + get() = _selfieUriResponse.value + + // ----- Survey status exposed as LiveData ----- + + private val _isSurveyComplete = MutableLiveData(false) + val isSurveyComplete: LiveData + get() = _isSurveyComplete + + private val _surveyScreenData = MutableLiveData() + val surveyScreenData: LiveData + get() = _surveyScreenData + + private val _isNextEnabled = MutableLiveData(false) + val isNextEnabled: LiveData + get() = _isNextEnabled + init { - viewModelScope.launch { - val survey = surveyRepository.getSurvey() - - // Create the default questions state based on the survey questions - val questions: List = survey.questions.mapIndexed { index, question -> - val showPrevious = index > 0 - val showDone = index == survey.questions.size - 1 - QuestionState( - question = question, - questionIndex = index, - totalQuestionsCount = survey.questions.size, - showPrevious = showPrevious, - showDone = showDone - ) - } - surveyInitialState = SurveyState.Questions(survey.title, questions) - _uiState.value = surveyInitialState + updateSurveyScreenData() + } + + /** + * Returns true if the ViewModel handled the back press (i.e., it went back one question) + */ + fun onBackPressed(): Boolean { + if (questionIndex == 0) { + return false } + changeQuestion(questionIndex - 1) + return true } - fun computeResult(surveyQuestions: SurveyState.Questions) { - val answers = surveyQuestions.questionsState.mapNotNull { it.answer } - val result = surveyRepository.getSurveyResult(answers) - _uiState.value = SurveyState.Result(surveyQuestions.surveyTitle, result) + fun onPreviousPressed() { + if (questionIndex == 0) { + throw IllegalStateException("onPreviousPressed when on question 0") + } + changeQuestion(questionIndex - 1) } - fun onDatePicked(questionId: Int, pickerSelection: Long) { - updateStateWithActionResult( - questionId, - SurveyActionResult.Date(pickerSelection) - ) + fun onNextPressed() { + changeQuestion(questionIndex + 1) } - fun getCurrentDate(questionId: Int): Long { - return getSelectedDate(questionId) + private fun changeQuestion(newQuestionIndex: Int) { + questionIndex = newQuestionIndex + _isNextEnabled.value = getIsNextEnabled() + updateSurveyScreenData() } - fun getUriToSaveImage(): Uri? { - uri = photoUriManager.buildNewUri() - return uri + fun onDonePressed() { + _isSurveyComplete.value = true } - fun onImageSaved() { - uri?.let { uri -> - getLatestQuestionId()?.let { questionId -> - updateStateWithActionResult(questionId, SurveyActionResult.Photo(uri)) - } + fun onFreeTimeResponse(selected: Boolean, answer: Int) { + if (selected) { + _freeTimeResponse.add(answer) + } else { + _freeTimeResponse.remove(answer) } + _isNextEnabled.value = getIsNextEnabled() } - // TODO: Ideally this should be stored in the database - fun doNotAskForPermissions() { - askForPermissions = false + fun onSuperheroResponse(superhero: Superhero) { + _superheroResponse.value = superhero + _isNextEnabled.value = getIsNextEnabled() } - private fun updateStateWithActionResult(questionId: Int, result: SurveyActionResult) { - val latestState = _uiState.value - if (latestState != null && latestState is SurveyState.Questions) { - val question = - latestState.questionsState.first { questionState -> - questionState.question.id == questionId - } - question.answer = Answer.Action(result) - question.enableNext = true - } + fun onTakeawayResponse(timestamp: Long) { + _takeawayResponse.value = timestamp + _isNextEnabled.value = getIsNextEnabled() } - private fun getLatestQuestionId(): Int? { - val latestState = _uiState.value - if (latestState != null && latestState is SurveyState.Questions) { - return latestState.questionsState[latestState.currentQuestionIndex].question.id - } - return null + fun onFeelingAboutSelfiesResponse(feeling: Float) { + _feelingAboutSelfiesResponse.value = feeling + _isNextEnabled.value = getIsNextEnabled() } - private fun getSelectedDate(questionId: Int): Long { - val latestState = _uiState.value - if (latestState != null && latestState is SurveyState.Questions) { - val question = - latestState.questionsState.first { questionState -> - questionState.question.id == questionId - } - val answer = question.answer as Answer.Action? - if (answer != null && answer.result is SurveyActionResult.Date) { - return answer.result.dateMillis - } + fun onSelfieResponse(uri: Uri) { + _selfieUriResponse.value = uri + _isNextEnabled.value = getIsNextEnabled() + } + private fun getIsNextEnabled(): Boolean { + return when (questionOrder[questionIndex]) { + SurveyQuestion.FREE_TIME -> _freeTimeResponse.isNotEmpty() + SurveyQuestion.SUPERHERO -> _superheroResponse.value != null + SurveyQuestion.LAST_TAKEAWAY -> _takeawayResponse.value != null + SurveyQuestion.FEELING_ABOUT_SELFIES -> _feelingAboutSelfiesResponse.value != null + SurveyQuestion.TAKE_SELFIE -> _selfieUriResponse.value != null } - return getDefaultDateInMillis() + } + + private fun updateSurveyScreenData() { + _surveyScreenData.value = SurveyScreenData( + questionIndex = questionIndex, + questionCount = questionOrder.size, + shouldShowPreviousButton = questionIndex > 0, + shouldShowDoneButton = questionIndex == questionOrder.size - 1, + surveyQuestion = questionOrder[questionIndex], + ) + } + + fun getUriToSaveImage(): Uri? { + uri = photoUriManager.buildNewUri() + return uri } } @@ -145,8 +176,24 @@ class SurveyViewModelFactory( @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(SurveyViewModel::class.java)) { - return SurveyViewModel(JetpackSurveyRepository, photoUriManager) as T + return SurveyViewModel(photoUriManager) as T } throw IllegalArgumentException("Unknown ViewModel class") } } + +enum class SurveyQuestion { + FREE_TIME, + SUPERHERO, + LAST_TAKEAWAY, + FEELING_ABOUT_SELFIES, + TAKE_SELFIE, +} + +data class SurveyScreenData( + val questionIndex: Int, + val questionCount: Int, + val shouldShowPreviousButton: Boolean, + val shouldShowDoneButton: Boolean, + val surveyQuestion: SurveyQuestion, +) diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/ActionQuestion.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/ActionQuestion.kt deleted file mode 100644 index 0d7eb87035..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/ActionQuestion.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey.question - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.example.compose.jetsurvey.survey.Answer -import com.example.compose.jetsurvey.survey.PossibleAnswer -import com.example.compose.jetsurvey.survey.SurveyActionType - -@Composable -fun ActionQuestion( - questionId: Int, - possibleAnswer: PossibleAnswer.Action, - answer: Answer.Action?, - onAction: (Int, SurveyActionType) -> Unit, - modifier: Modifier = Modifier -) { - when (possibleAnswer.actionType) { - SurveyActionType.PICK_DATE -> { - DateQuestion( - questionId = questionId, - answer = answer, - onAction = onAction, - modifier = modifier - ) - } - SurveyActionType.TAKE_PHOTO -> { - PhotoQuestion( - questionId = questionId, - answer = answer, - onAction = onAction, - modifier = modifier - ) - } - SurveyActionType.SELECT_CONTACT -> TODO() - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/ChoiceQuestion.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/ChoiceQuestion.kt deleted file mode 100644 index 18140fbfe1..0000000000 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/ChoiceQuestion.kt +++ /dev/null @@ -1,250 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.compose.jetsurvey.survey.question - -import android.content.res.Configuration -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.material3.Checkbox -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.compose.ui.unit.dp -import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.survey.Answer -import com.example.compose.jetsurvey.survey.AnswerOption -import com.example.compose.jetsurvey.survey.PossibleAnswer -import com.example.compose.jetsurvey.theme.JetsurveyTheme - -/** - * Shows a list of possible answers with image and text and a radio button. - * @param options a list of all possible answers with their icon and text value - * @param answer the chosen answer (identified by text resource value), can be null if no answer is - * chosen - * @param onAnswerSelected callback that is called when a possible answer is clicked - * @param modifier Modifier to be applied to the [SingleChoiceQuestion] - */ -@Composable -fun SingleChoiceQuestion( - options: List, - answer: Answer.SingleChoice?, - onAnswerSelected: (Int) -> Unit, - modifier: Modifier = Modifier -) { - Column(modifier.selectableGroup()) { - options.forEach { option -> - Answer( - text = stringResource(option.textRes), - painter = option.iconRes?.let { painterResource(it) }, - selected = option.textRes == answer?.answer, - onOptionSelected = { onAnswerSelected(option.textRes) }, - isSingleChoice = true, - modifier = Modifier.padding(vertical = 8.dp) - ) - } - } -} - -@Composable -fun MultipleChoiceQuestion( - possibleAnswer: PossibleAnswer.MultipleChoice, - answer: Answer.MultipleChoice?, - onAnswerSelected: (Int, Boolean) -> Unit, - modifier: Modifier = Modifier -) { - Column(modifier) { - possibleAnswer.options.forEach { option -> - val selected = answer?.answersStringRes?.contains(option.textRes) ?: false - Answer( - text = stringResource(option.textRes), - painter = option.iconRes?.let { painterResource(it) }, - selected = selected, - onOptionSelected = { onAnswerSelected(option.textRes, !selected) }, - isSingleChoice = false, - modifier = Modifier.padding(vertical = 8.dp) - ) - } - } -} - -@Composable -private fun Answer( - text: String, - painter: Painter?, - selected: Boolean, - onOptionSelected: () -> Unit, - isSingleChoice: Boolean, - modifier: Modifier = Modifier -) { - Surface( - shape = MaterialTheme.shapes.small, - color = if (selected) { - MaterialTheme.colorScheme.primaryContainer - } else { - MaterialTheme.colorScheme.surface - }, - border = BorderStroke( - width = 1.dp, - color = if (selected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline - } - ), - modifier = modifier - .clip(MaterialTheme.shapes.small) - .then( - if (isSingleChoice) { - Modifier.selectable( - selected, - onClick = onOptionSelected, - role = Role.RadioButton - ) - } else { - Modifier.clickable(onClick = onOptionSelected) - } - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (painter != null) { - Image( - painter = painter, - contentDescription = null, - modifier = Modifier - .size(56.dp) - .clip(MaterialTheme.shapes.extraSmall) - ) - Spacer(Modifier.width(8.dp)) - } - Text(text, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge) - Box(Modifier.padding(8.dp)) { - if (isSingleChoice) { - RadioButton(selected, onClick = null) - } else { - Checkbox(selected, onCheckedChange = null) - } - } - } - } -} - -@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun AnswerPreview( - @PreviewParameter(PreviewDataProvider::class, limit = 4) previewData: PreviewData -) { - JetsurveyTheme { - Answer( - text = "Preview", - painter = painterResource(id = R.drawable.frag), - selected = previewData.selected, - isSingleChoice = previewData.isSingleChoice, - onOptionSelected = { } - ) - } -} - -private data class PreviewData( - val selected: Boolean, - val isSingleChoice: Boolean -) - -private class PreviewDataProvider : PreviewParameterProvider { - override val values = sequenceOf( - PreviewData(selected = false, isSingleChoice = true), - PreviewData(selected = true, isSingleChoice = true), - PreviewData(selected = false, isSingleChoice = false), - PreviewData(selected = true, isSingleChoice = false), - ) -} - -@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun SingleChoiceIconQuestionPreview() { - var selectedAnswer: Answer.SingleChoice? by remember { - mutableStateOf(Answer.SingleChoice(R.string.bugchaos)) - } - - JetsurveyTheme { - SingleChoiceQuestion( - options = listOf( - AnswerOption(R.string.spark, R.drawable.spark), - AnswerOption(R.string.lenz, R.drawable.lenz), - AnswerOption(R.string.bugchaos, R.drawable.bug_of_chaos), - AnswerOption(R.string.frag, R.drawable.frag) - ), - answer = selectedAnswer, - onAnswerSelected = { textRes -> selectedAnswer = Answer.SingleChoice(textRes) } - ) - } -} - -@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun SingleChoiceQuestionPreview() { - var selectedAnswer: Answer.SingleChoice? by remember { - mutableStateOf(Answer.SingleChoice(R.string.bugchaos)) - } - - JetsurveyTheme { - SingleChoiceQuestion( - options = listOf( - AnswerOption(R.string.star_trek), - AnswerOption(R.string.social_network), - AnswerOption(R.string.back_to_future), - AnswerOption(R.string.outbreak) - ), - answer = selectedAnswer, - onAnswerSelected = { textRes -> selectedAnswer = Answer.SingleChoice(textRes) } - ) - } -} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/DateQuestion.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/DateQuestion.kt index aec69f7be9..da135f53ae 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/DateQuestion.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/DateQuestion.kt @@ -17,6 +17,7 @@ package com.example.compose.jetsurvey.survey.question import android.content.res.Configuration +import androidx.annotation.StringRes import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -33,9 +34,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.example.compose.jetsurvey.survey.Answer -import com.example.compose.jetsurvey.survey.SurveyActionResult -import com.example.compose.jetsurvey.survey.SurveyActionType +import com.example.compose.jetsurvey.R +import com.example.compose.jetsurvey.survey.QuestionWrapper import com.example.compose.jetsurvey.survey.simpleDateFormatPattern import com.example.compose.jetsurvey.theme.JetsurveyTheme import com.example.compose.jetsurvey.theme.slightlyDeemphasizedAlpha @@ -46,49 +46,49 @@ import java.util.TimeZone @Composable fun DateQuestion( - questionId: Int, - answer: Answer.Action?, - onAction: (Int, SurveyActionType) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + @StringRes titleResourceId: Int, + @StringRes directionResourceId: Int, + dateInMillis: Long?, + onClick: () -> Unit, ) { - val timestamp = if (answer != null && answer.result is SurveyActionResult.Date) { - answer.result.dateMillis - } else { - getDefaultDateInMillis() - } - - // All times are stored in UTC, so generate the display from UTC also - val dateFormat = SimpleDateFormat(simpleDateFormatPattern, Locale.getDefault()) - dateFormat.timeZone = TimeZone.getTimeZone("UTC") - val dateString = dateFormat.format(timestamp) - - Button( - onClick = { onAction(questionId, SurveyActionType.PICK_DATE) }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface - .copy(alpha = slightlyDeemphasizedAlpha), - ), - shape = MaterialTheme.shapes.small, - modifier = modifier - .padding(vertical = 20.dp) - .height(54.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)) - + QuestionWrapper( + modifier = modifier, + titleResourceId = titleResourceId, + directionResourceId = directionResourceId, ) { - Text( - text = dateString, - modifier = Modifier - .fillMaxWidth() - .weight(1.8f) - ) - Icon( - imageVector = Icons.Filled.ArrowDropDown, - contentDescription = null, + // All times are stored in UTC, so generate the display from UTC also + val dateFormat = SimpleDateFormat(simpleDateFormatPattern, Locale.getDefault()) + dateFormat.timeZone = TimeZone.getTimeZone("UTC") + val dateString = dateFormat.format(dateInMillis ?: getDefaultDateInMillis()) + + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface + .copy(alpha = slightlyDeemphasizedAlpha), + ), + shape = MaterialTheme.shapes.small, modifier = Modifier - .fillMaxWidth() - .weight(0.2f) - ) + .padding(vertical = 20.dp) + .height(54.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)), + ) { + Text( + text = dateString, + modifier = Modifier + .fillMaxWidth() + .weight(1.8f) + ) + Icon( + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .weight(0.2f) + ) + } } } @@ -98,7 +98,12 @@ fun DateQuestion( fun DateQuestionPreview() { JetsurveyTheme { Surface { - DateQuestion(questionId = 1, answer = null, onAction = { _, _ -> }) + DateQuestion( + titleResourceId = R.string.takeaway, + directionResourceId = R.string.select_date, + dateInMillis = 1672560000000, // 2023-01-01 + onClick = {}, + ) } } } diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/MultipleChoiceQuestion.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/MultipleChoiceQuestion.kt new file mode 100644 index 0000000000..152772499c --- /dev/null +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/MultipleChoiceQuestion.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.jetsurvey.survey.question + +import androidx.annotation.StringRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.compose.jetsurvey.R +import com.example.compose.jetsurvey.survey.QuestionWrapper + +@Composable +fun MultipleChoiceQuestion( + modifier: Modifier = Modifier, + @StringRes titleResourceId: Int, + @StringRes directionResourceId: Int, + possibleAnswers: List, + selectedAnswers: List, + onOptionSelected: (selected: Boolean, answer: Int) -> Unit, +) { + QuestionWrapper( + modifier = modifier, + titleResourceId = titleResourceId, + directionResourceId = directionResourceId, + ) { + possibleAnswers.forEach { + val selected = selectedAnswers.contains(it) + CheckboxRow( + modifier = Modifier.padding(vertical = 8.dp), + text = stringResource(id = it), + selected = selected, + onOptionSelected = { onOptionSelected(!selected, it) } + ) + } + } +} + +@Composable +fun CheckboxRow( + modifier: Modifier = Modifier, + text: String, + selected: Boolean, + onOptionSelected: () -> Unit, +) { + Surface( + shape = MaterialTheme.shapes.small, + color = if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + border = BorderStroke( + width = 1.dp, + color = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + } + ), + modifier = modifier + .clip(MaterialTheme.shapes.small) + .clickable(onClick = onOptionSelected) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge) + Box(Modifier.padding(8.dp)) { + Checkbox(selected, onCheckedChange = null) + } + } + } +} + +@Preview +@Composable +fun MultipleChoiceQuestionPreview() { + val possibleAnswers = listOf(R.string.read, R.string.work_out, R.string.draw) + val selectedAnswers = remember { mutableStateListOf(R.string.work_out) } + MultipleChoiceQuestion( + titleResourceId = R.string.in_my_free_time, + directionResourceId = R.string.select_all, + possibleAnswers = possibleAnswers, + selectedAnswers = selectedAnswers, + onOptionSelected = { _, _ -> } + ) +} diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/PhotoQuestion.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/PhotoQuestion.kt index aa432d9060..397bdcdf48 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/PhotoQuestion.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/PhotoQuestion.kt @@ -17,6 +17,8 @@ package com.example.compose.jetsurvey.survey.question import android.content.res.Configuration +import android.net.Uri +import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -49,63 +51,72 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.survey.Answer -import com.example.compose.jetsurvey.survey.SurveyActionResult -import com.example.compose.jetsurvey.survey.SurveyActionType +import com.example.compose.jetsurvey.survey.QuestionWrapper import com.example.compose.jetsurvey.theme.JetsurveyTheme @Composable fun PhotoQuestion( - questionId: Int, - answer: Answer.Action?, - onAction: (Int, SurveyActionType) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + @StringRes titleResourceId: Int, + imageUri: Uri?, + onClick: () -> Unit, ) { - val resource = if (answer != null) { + val iconResource = if (imageUri != null) { Icons.Filled.SwapHoriz } else { Icons.Filled.AddAPhoto } - OutlinedButton( - onClick = { onAction(questionId, SurveyActionType.TAKE_PHOTO) }, + + QuestionWrapper( modifier = modifier, - shape = MaterialTheme.shapes.small, - contentPadding = PaddingValues() + titleResourceId = titleResourceId, ) { - Column { - if (answer != null && answer.result is SurveyActionResult.Photo) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(answer.result.uri) - .crossfade(true) - .build(), - contentDescription = null, + + OutlinedButton( + onClick = onClick, + shape = MaterialTheme.shapes.small, + contentPadding = PaddingValues() + ) { + Column { + if (imageUri != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUri) + .crossfade(true) + .build(), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .heightIn(96.dp) + .aspectRatio(4 / 3f) + ) + } else { + PhotoDefaultImage( + modifier = Modifier.padding( + horizontal = 86.dp, + vertical = 74.dp + ) + ) + } + Row( modifier = Modifier .fillMaxWidth() - .heightIn(96.dp) - .aspectRatio(4 / 3f) - ) - } else { - PhotoDefaultImage(modifier = Modifier.padding(horizontal = 86.dp, vertical = 74.dp)) - } - Row( - modifier = Modifier - .fillMaxWidth() - .wrapContentSize(Alignment.BottomCenter) - .padding(vertical = 26.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(imageVector = resource, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource( - id = if (answer != null) { - R.string.retake_photo - } else { - R.string.add_photo - } + .wrapContentSize(Alignment.BottomCenter) + .padding(vertical = 26.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = iconResource, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource( + id = if (imageUri != null) { + R.string.retake_photo + } else { + R.string.add_photo + } + ) ) - ) + } } } } @@ -134,7 +145,12 @@ private fun PhotoDefaultImage( fun PhotoQuestionPreview() { JetsurveyTheme { Surface { - PhotoQuestion(questionId = 1, answer = null, onAction = { _, _ -> }) + PhotoQuestion( + modifier = Modifier.padding(16.dp), + titleResourceId = R.string.selfie_skills, + imageUri = null, + onClick = {}, + ) } } } diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/SingleChoiceQuestion.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/SingleChoiceQuestion.kt new file mode 100644 index 0000000000..8c757aff7e --- /dev/null +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/SingleChoiceQuestion.kt @@ -0,0 +1,153 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.jetsurvey.survey.question + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.example.compose.jetsurvey.R +import com.example.compose.jetsurvey.survey.QuestionWrapper + +@Composable +fun SingleChoiceQuestion( + modifier: Modifier = Modifier, + @StringRes titleResourceId: Int, + @StringRes directionResourceId: Int, + possibleAnswers: List, + selectedAnswer: Superhero?, + onOptionSelected: (Superhero) -> Unit, +) { + QuestionWrapper( + modifier = modifier.selectableGroup(), + titleResourceId = titleResourceId, + directionResourceId = directionResourceId, + ) { + possibleAnswers.forEach { + val selected = it == selectedAnswer + RadioButtonWithImageRow( + modifier = Modifier.padding(vertical = 8.dp), + text = stringResource(id = it.stringResourceId), + imageResourceId = it.imageResourceId, + selected = selected, + onOptionSelected = { onOptionSelected(it) } + ) + } + } +} + +@Composable +fun RadioButtonWithImageRow( + modifier: Modifier = Modifier, + text: String, + @DrawableRes imageResourceId: Int, + selected: Boolean, + onOptionSelected: () -> Unit, +) { + Surface( + shape = MaterialTheme.shapes.small, + color = if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + border = BorderStroke( + width = 1.dp, + color = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + } + ), + modifier = modifier + .clip(MaterialTheme.shapes.small) + .selectable( + selected, + onClick = onOptionSelected, + role = Role.RadioButton + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = imageResourceId), + contentDescription = null, + modifier = Modifier + .size(56.dp) + .clip(MaterialTheme.shapes.extraSmall) + .padding(start = 0.dp, end = 8.dp) + ) + Spacer(Modifier.width(8.dp)) + + Text(text, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge) + Box(Modifier.padding(8.dp)) { + RadioButton(selected, onClick = null) + } + } + } +} + +@Preview +@Composable +fun SingleChoiceQuestionPreview() { + val possibleAnswers = listOf( + Superhero(R.string.spark, R.drawable.spark), + Superhero(R.string.lenz, R.drawable.lenz), + Superhero(R.string.bugchaos, R.drawable.bug_of_chaos), + ) + var selectedAnswer by remember { mutableStateOf(null) } + + SingleChoiceQuestion( + titleResourceId = R.string.pick_superhero, + directionResourceId = R.string.select_one, + possibleAnswers = possibleAnswers, + selectedAnswer = selectedAnswer, + onOptionSelected = { selectedAnswer = it }, + ) +} + +data class Superhero(@StringRes val stringResourceId: Int, @DrawableRes val imageResourceId: Int) diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/SliderQuestion.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/SliderQuestion.kt index 75b4d5b358..905b691b69 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/SliderQuestion.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/SliderQuestion.kt @@ -17,7 +17,7 @@ package com.example.compose.jetsurvey.survey.question import android.content.res.Configuration -import androidx.compose.foundation.layout.Column +import androidx.annotation.StringRes import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -36,30 +36,38 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.example.compose.jetsurvey.R -import com.example.compose.jetsurvey.survey.Answer -import com.example.compose.jetsurvey.survey.PossibleAnswer +import com.example.compose.jetsurvey.survey.QuestionWrapper import com.example.compose.jetsurvey.theme.JetsurveyTheme @Composable fun SliderQuestion( - possibleAnswer: PossibleAnswer.Slider, - answer: Answer.Slider?, - onAnswerSelected: (Float) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + @StringRes titleResourceId: Int, + value: Float?, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange = 0f..1f, + steps: Int = 3, + @StringRes startTextResource: Int, + @StringRes neutralTextResource: Int, + @StringRes endTextResource: Int, ) { var sliderPosition by remember { - mutableStateOf(answer?.answerValue ?: possibleAnswer.defaultValue) + mutableStateOf(value ?: ((valueRange.endInclusive - valueRange.start) / 2)) } - Column(modifier = modifier) { + QuestionWrapper( + modifier = modifier, + titleResourceId = titleResourceId, + ) { + Row { Slider( value = sliderPosition, onValueChange = { sliderPosition = it - onAnswerSelected(it) + onValueChange(it) }, - valueRange = possibleAnswer.range, - steps = possibleAnswer.steps, + valueRange = valueRange, + steps = steps, modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth() @@ -67,7 +75,7 @@ fun SliderQuestion( } Row { Text( - text = stringResource(id = possibleAnswer.startText), + text = stringResource(id = startTextResource), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Start, modifier = Modifier @@ -75,7 +83,7 @@ fun SliderQuestion( .weight(1.8f) ) Text( - text = stringResource(id = possibleAnswer.neutralText), + text = stringResource(id = neutralTextResource), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center, modifier = Modifier @@ -83,7 +91,7 @@ fun SliderQuestion( .weight(1.8f) ) Text( - text = stringResource(id = possibleAnswer.endText), + text = stringResource(id = endTextResource), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.End, modifier = Modifier @@ -101,15 +109,12 @@ fun SliderQuestionPreview() { JetsurveyTheme { Surface { SliderQuestion( - possibleAnswer = PossibleAnswer.Slider( - range = 1f..10f, - steps = 3, - startText = R.string.strongly_dislike, - endText = R.string.strongly_like, - neutralText = R.string.neutral - ), - answer = Answer.Slider(5f), - onAnswerSelected = {} + titleResourceId = R.string.selfies, + value = 0.4f, + onValueChange = {}, + startTextResource = R.string.strongly_dislike, + endTextResource = R.string.strongly_like, + neutralTextResource = R.string.neutral ) } } diff --git a/Jetsurvey/app/src/main/res/navigation/nav_graph.xml b/Jetsurvey/app/src/main/res/navigation/nav_graph.xml index 20feb5e825..fb8db56c4a 100644 --- a/Jetsurvey/app/src/main/res/navigation/nav_graph.xml +++ b/Jetsurvey/app/src/main/res/navigation/nav_graph.xml @@ -17,7 +17,6 @@ --> Hide password - Which Jetpack library are you? \u00A0of %d Select one. Select all that apply. @@ -64,7 +63,6 @@ When was the last time you ordered takeaway because you couldn\'t be bothered to cook? - Pick a date How do you feel about selfies 🤳? @@ -73,27 +71,12 @@ Show off your selfie skills! ADD PHOTO RETAKE PHOTO - Taking a selfie requires this app to have access to your device storage. - - What\'s your favourite movie? - Star Trek - The social network - Back to the future - Outbreak - - Congratulations, you are %s + Compose + Congratulations, you are Compose You are a curious developer, always willing to try - something new. You want to stay up to date with the trends to Compose is your middle name + something new. You want to stay up to date with the trends to Compose is your middle name. Strongly\nDislike Strongly\nLike Neutral - - - This question requires some permissions. Please grant them. - Permissions were denied. Unfortunately, this question is not available at the moment. Please continue with the survey or grant access to the permissions on the Settings screen. - OK! - Nope 🙅 - Open Settings - diff --git a/Jetsurvey/app/src/test/java/com/example/compose/jetsurvey/survey/SurveyViewModelTest.kt b/Jetsurvey/app/src/test/java/com/example/compose/jetsurvey/survey/SurveyViewModelTest.kt index 46f8aa9332..aeb0be796d 100644 --- a/Jetsurvey/app/src/test/java/com/example/compose/jetsurvey/survey/SurveyViewModelTest.kt +++ b/Jetsurvey/app/src/test/java/com/example/compose/jetsurvey/survey/SurveyViewModelTest.kt @@ -18,7 +18,7 @@ package com.example.compose.jetsurvey.survey import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -31,42 +31,34 @@ class SurveyViewModelTest { @Before fun setUp() { viewModel = SurveyViewModel( - TestSurveyRepository(), PhotoUriManager(ApplicationProvider.getApplicationContext()) ) } @Test - fun onDatePicked_storesValueCorrectly() { - // Select a date - val initialDateMilliseconds = 0L - viewModel.onDatePicked(dateQuestionId, initialDateMilliseconds) + fun onFreeTimeResponse() { + val answerOne = 0 + val answerTwo = 99 - // Get the stored date - val newDateMilliseconds = viewModel.getCurrentDate(dateQuestionId) + // Starts empty, next disabled + Truth.assertThat(viewModel.freeTimeResponse).isEmpty() + Truth.assertThat(viewModel.isNextEnabled.value).isFalse() - // Verify they're identical - assertThat(newDateMilliseconds).isEqualTo(initialDateMilliseconds) - } -} - -const val dateQuestionId = 1 -class TestSurveyRepository : SurveyRepository { + // Add two arbitrary values + viewModel.onFreeTimeResponse(true, answerOne) + viewModel.onFreeTimeResponse(true, answerTwo) + Truth.assertThat(viewModel.freeTimeResponse).containsExactly(answerOne, answerTwo) + Truth.assertThat(viewModel.isNextEnabled.value).isTrue() - private val testSurvey = Survey( - title = -1, - questions = listOf( - Question( - id = dateQuestionId, - questionText = -1, - answer = PossibleAnswer.Action(label = -1, SurveyActionType.PICK_DATE) - ) - ) - ) - - override fun getSurvey() = testSurvey + // Remove one value + viewModel.onFreeTimeResponse(false, answerTwo) + Truth.assertThat(viewModel.freeTimeResponse).containsExactly(answerOne) + Truth.assertThat(viewModel.isNextEnabled.value).isTrue() - override fun getSurveyResult(answers: List>): SurveyResult { - TODO("Not yet implemented") + // Remove the last value + viewModel.onFreeTimeResponse(false, answerOne) + Truth.assertThat(viewModel.freeTimeResponse).isEmpty() + Truth.assertThat(viewModel.isNextEnabled.value).isFalse() } + } diff --git a/Jetsurvey/gradle/libs.versions.toml b/Jetsurvey/gradle/libs.versions.toml index eaf72fc4ec..0d6e93a4ca 100644 --- a/Jetsurvey/gradle/libs.versions.toml +++ b/Jetsurvey/gradle/libs.versions.toml @@ -42,8 +42,7 @@ material = "1.8.0-alpha02" # @keep minSdk = "21" okhttp = "4.10.0" -# @pin Bump to latest after Espresso 3.5.0 goes stable (due to https://github.com/robolectric/robolectric/issues/6593) -robolectric = "4.5.1" +robolectric = "4.9.1" rome = "1.18.0" room = "2.5.0-alpha02" secrets = "2.0.1" From c60ff1a555cc15e00b290461ffb3b6cb8b0a817c Mon Sep 17 00:00:00 2001 From: "Ian G. Clifton" Date: Tue, 27 Dec 2022 16:01:58 -0800 Subject: [PATCH 2/7] Formatting fix --- .../com/example/compose/jetsurvey/survey/SurveyViewModelTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/Jetsurvey/app/src/test/java/com/example/compose/jetsurvey/survey/SurveyViewModelTest.kt b/Jetsurvey/app/src/test/java/com/example/compose/jetsurvey/survey/SurveyViewModelTest.kt index aeb0be796d..29941883a6 100644 --- a/Jetsurvey/app/src/test/java/com/example/compose/jetsurvey/survey/SurveyViewModelTest.kt +++ b/Jetsurvey/app/src/test/java/com/example/compose/jetsurvey/survey/SurveyViewModelTest.kt @@ -60,5 +60,4 @@ class SurveyViewModelTest { Truth.assertThat(viewModel.freeTimeResponse).isEmpty() Truth.assertThat(viewModel.isNextEnabled.value).isFalse() } - } From 07896052ce3f25208b255e2424d25e308fdd89b3 Mon Sep 17 00:00:00 2001 From: "Ian G. Clifton" Date: Tue, 27 Dec 2022 16:18:01 -0800 Subject: [PATCH 3/7] Spacing tweaks --- .../com/example/compose/jetsurvey/survey/SurveyQuestions.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt index ebd21f866d..7495e53327 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt @@ -53,8 +53,10 @@ fun QuestionWrapper( Spacer(Modifier.height(32.dp)) QuestionTitle(titleResourceId) directionResourceId?.let { + Spacer(Modifier.height(18.dp)) QuestionDirections(it) } + Spacer(Modifier.height(18.dp)) content() } @@ -85,6 +87,6 @@ fun QuestionDirections(@StringRes directionsResourceId: Int) { style = MaterialTheme.typography.bodySmall, modifier = Modifier .fillMaxWidth() - .padding(vertical = 18.dp, horizontal = 8.dp) + .padding(horizontal = 8.dp) ) } From d6fae17b1b03257bb38816ced7b0810048f6ca83 Mon Sep 17 00:00:00 2001 From: "Ian G. Clifton" Date: Tue, 27 Dec 2022 18:04:27 -0800 Subject: [PATCH 4/7] Speed up transition between questions --- .../java/com/example/compose/jetsurvey/survey/SurveyFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt index 5fe01234d3..fd2b534ccc 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt @@ -218,6 +218,6 @@ class SurveyFragment : Fragment() { } companion object { - private const val CONTENT_ANIMATION_DURATION = 500 + private const val CONTENT_ANIMATION_DURATION = 300 } } From 61958afb227150603365a891b30a8ad2b169e38e Mon Sep 17 00:00:00 2001 From: "Ian G. Clifton" Date: Tue, 3 Jan 2023 11:01:39 -0800 Subject: [PATCH 5/7] Updates from code review feedback --- Jetsurvey/app/build.gradle.kts | 1 - ...{SurveyQuestions.kt => QuestionWrapper.kt} | 29 ++++++--- .../compose/jetsurvey/survey/Survey.kt | 26 ++++---- .../jetsurvey/survey/SurveyFragment.kt | 59 +++++++------------ .../compose/jetsurvey/survey/SurveyScreen.kt | 12 ++-- .../jetsurvey/survey/SurveyViewModel.kt | 32 ++++------ .../jetsurvey/survey/question/DateQuestion.kt | 10 ++-- .../survey/question/MultipleChoiceQuestion.kt | 10 ++-- .../survey/question/PhotoQuestion.kt | 6 +- .../survey/question/SingleChoiceQuestion.kt | 12 ++-- .../survey/question/SliderQuestion.kt | 4 +- 11 files changed, 94 insertions(+), 107 deletions(-) rename Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/{SurveyQuestions.kt => QuestionWrapper.kt} (78%) diff --git a/Jetsurvey/app/build.gradle.kts b/Jetsurvey/app/build.gradle.kts index ecab069b1e..16dec2ed90 100644 --- a/Jetsurvey/app/build.gradle.kts +++ b/Jetsurvey/app/build.gradle.kts @@ -83,7 +83,6 @@ dependencies { implementation(libs.androidx.compose.material.iconsExtended) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.runtime) - implementation(libs.androidx.compose.runtime.livedata) debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.accompanist.permissions) diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/QuestionWrapper.kt similarity index 78% rename from Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt rename to Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/QuestionWrapper.kt index 7495e53327..1c1c7f30c7 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyQuestions.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/QuestionWrapper.kt @@ -35,14 +35,19 @@ import com.example.compose.jetsurvey.theme.slightlyDeemphasizedAlpha import com.example.compose.jetsurvey.theme.stronglyDeemphasizedAlpha /** - * Creates a Column that is vertically scrollable and contains the title, the directions (if a - * string resource is passed), and the content + * A scrollable container with the question's title, direction, and dynamic content. + * + * @param titleResourceId String resource to use for the question's title + * @param modifier Modifier to apply to the entire wrapper + * @param directionsResourceId String resource to use for the question's directions; the direction + * UI will be omitted if null is passed + * @param content Composable to display after the title and option directions */ @Composable fun QuestionWrapper( - modifier: Modifier = Modifier, @StringRes titleResourceId: Int, - @StringRes directionResourceId: Int? = null, + modifier: Modifier = Modifier, + @StringRes directionsResourceId: Int? = null, content: @Composable () -> Unit, ) { Column( @@ -52,7 +57,7 @@ fun QuestionWrapper( ) { Spacer(Modifier.height(32.dp)) QuestionTitle(titleResourceId) - directionResourceId?.let { + directionsResourceId?.let { Spacer(Modifier.height(18.dp)) QuestionDirections(it) } @@ -63,12 +68,15 @@ fun QuestionWrapper( } @Composable -fun QuestionTitle(@StringRes title: Int) { +private fun QuestionTitle( + @StringRes title: Int, + modifier: Modifier = Modifier, +) { Text( text = stringResource(id = title), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = slightlyDeemphasizedAlpha), - modifier = Modifier + modifier = modifier .fillMaxWidth() .background( color = MaterialTheme.colorScheme.inverseOnSurface, @@ -79,13 +87,16 @@ fun QuestionTitle(@StringRes title: Int) { } @Composable -fun QuestionDirections(@StringRes directionsResourceId: Int) { +private fun QuestionDirections( + @StringRes directionsResourceId: Int, + modifier: Modifier = Modifier, +) { Text( text = stringResource(id = directionsResourceId), color = MaterialTheme.colorScheme.onSurface .copy(alpha = stronglyDeemphasizedAlpha), style = MaterialTheme.typography.bodySmall, - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(horizontal = 8.dp) ) diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/Survey.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/Survey.kt index ed4940aafc..e39af10b92 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/Survey.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/Survey.kt @@ -29,14 +29,13 @@ import com.example.compose.jetsurvey.survey.question.Superhero @Composable fun FreeTimeQuestion( - modifier: Modifier = Modifier, selectedAnswers: List, onOptionSelected: (selected: Boolean, answer: Int) -> Unit, + modifier: Modifier = Modifier, ) { MultipleChoiceQuestion( - modifier = modifier, titleResourceId = R.string.in_my_free_time, - directionResourceId = R.string.select_all, + directionsResourceId = R.string.select_all, possibleAnswers = listOf( R.string.read, R.string.work_out, @@ -47,19 +46,19 @@ fun FreeTimeQuestion( ), selectedAnswers = selectedAnswers, onOptionSelected = onOptionSelected, + modifier = modifier, ) } @Composable fun SuperheroQuestion( - modifier: Modifier = Modifier, selectedAnswer: Superhero?, onOptionSelected: (Superhero) -> Unit, + modifier: Modifier = Modifier, ) { SingleChoiceQuestion( - modifier = modifier, titleResourceId = R.string.pick_superhero, - directionResourceId = R.string.select_one, + directionsResourceId = R.string.select_one, possibleAnswers = listOf( Superhero(R.string.spark, R.drawable.spark), Superhero(R.string.lenz, R.drawable.lenz), @@ -68,51 +67,52 @@ fun SuperheroQuestion( ), selectedAnswer = selectedAnswer, onOptionSelected = onOptionSelected, + modifier = modifier, ) } @Composable fun TakeawayQuestion( - modifier: Modifier = Modifier, dateInMillis: Long?, onClick: () -> Unit, + modifier: Modifier = Modifier, ) { DateQuestion( - modifier = modifier, titleResourceId = R.string.takeaway, - directionResourceId = R.string.select_date, + directionsResourceId = R.string.select_date, dateInMillis = dateInMillis, onClick = onClick, + modifier = modifier, ) } @Composable fun FeelingAboutSelfiesQuestion( - modifier: Modifier = Modifier, value: Float?, onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, ) { SliderQuestion( - modifier = modifier, titleResourceId = R.string.selfies, value = value, onValueChange = onValueChange, startTextResource = R.string.strongly_dislike, neutralTextResource = R.string.neutral, endTextResource = R.string.strongly_like, + modifier = modifier, ) } @Composable fun TakeSelfieQuestion( - modifier: Modifier = Modifier, imageUri: Uri?, onClick: () -> Unit, + modifier: Modifier = Modifier, ) { PhotoQuestion( - modifier = modifier, titleResourceId = R.string.selfie_skills, imageUri = imageUri, onClick = onClick, + modifier = modifier, ) } diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt index fd2b534ccc..bef266cee4 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyFragment.kt @@ -31,13 +31,11 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.with import androidx.compose.foundation.layout.padding import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels @@ -76,24 +74,14 @@ class SurveyFragment : Fragment() { setContent { JetsurveyTheme { - val isSurveyComplete by viewModel.isSurveyComplete.observeAsState(false) - - if (isSurveyComplete) { - // Note, results are hardcoded for this demo; in a complete app, you'd - // likely send the survey responses to a backend to determine the - // result and then pass them here. - SurveyResultScreen( - title = stringResource(R.string.survey_result_title), - subtitle = stringResource(R.string.survey_result_subtitle), - description = stringResource(R.string.survey_result_description), - onDonePressed = { - activity?.onBackPressedDispatcher?.onBackPressed() - } - ) + + if (viewModel.isSurveyComplete) { + SurveyResultScreen { + activity?.onBackPressedDispatcher?.onBackPressed() + } } else { - val surveyScreenData = viewModel.surveyScreenData.observeAsState().value + val surveyScreenData = viewModel.surveyScreenData ?: return@JetsurveyTheme - val isNextEnabled = viewModel.isNextEnabled.observeAsState().value ?: false var shouldInterceptBackPresses by remember { mutableStateOf(true) } BackHandler { @@ -104,7 +92,7 @@ class SurveyFragment : Fragment() { SurveyQuestionsScreen( surveyScreenData = surveyScreenData, - isNextEnabled = isNextEnabled, + isNextEnabled = viewModel.isNextEnabled, onClosePressed = { shouldInterceptBackPresses = false activity?.onBackPressedDispatcher?.onBackPressed() @@ -138,39 +126,36 @@ class SurveyFragment : Fragment() { when (targetState.surveyQuestion) { SurveyQuestion.FREE_TIME -> { FreeTimeQuestion( - modifier, - viewModel.freeTimeResponse, - ) { selected, answer -> - viewModel.onFreeTimeResponse(selected, answer) - } + selectedAnswers = viewModel.freeTimeResponse, + onOptionSelected = viewModel::onFreeTimeResponse, + modifier = modifier, + ) } SurveyQuestion.SUPERHERO -> SuperheroQuestion( - modifier, - viewModel.superheroResponse, - ) { superhero -> - viewModel.onSuperheroResponse(superhero) - } + selectedAnswer = viewModel.superheroResponse, + onOptionSelected = viewModel::onSuperheroResponse, + modifier = modifier, + ) SurveyQuestion.LAST_TAKEAWAY -> TakeawayQuestion( - modifier, dateInMillis = viewModel.takeawayResponse, - onClick = { showTakeawayDatePicker() } + onClick = ::showTakeawayDatePicker, + modifier = modifier, ) SurveyQuestion.FEELING_ABOUT_SELFIES -> FeelingAboutSelfiesQuestion( - modifier = modifier, value = viewModel.feelingAboutSelfiesResponse, - onValueChange = { feeling -> - viewModel.onFeelingAboutSelfiesResponse(feeling) - } + onValueChange = + viewModel::onFeelingAboutSelfiesResponse, + modifier = modifier, ) SurveyQuestion.TAKE_SELFIE -> TakeSelfieQuestion( - modifier = modifier, imageUri = viewModel.selfieUriResponse, - onClick = { takeSelfie() } + onClick = ::takeSelfie, + modifier = modifier, ) } } diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyScreen.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyScreen.kt index 093676c366..85edcdce77 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyScreen.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyScreen.kt @@ -90,19 +90,17 @@ fun SurveyQuestionsScreen( @OptIn(ExperimentalMaterial3Api::class) // Scaffold is experimental in m3 @Composable fun SurveyResultScreen( - title: String, - subtitle: String, - description: String, - onDonePressed: () -> Unit + onDonePressed: () -> Unit, ) { + Surface(modifier = Modifier.supportWideScreen()) { Scaffold( content = { innerPadding -> val modifier = Modifier.padding(innerPadding) SurveyResult( - title = title, - subtitle = subtitle, - description = description, + title = stringResource(R.string.survey_result_title), + subtitle = stringResource(R.string.survey_result_subtitle), + description = stringResource(R.string.survey_result_description), modifier = modifier ) }, diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyViewModel.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyViewModel.kt index ae76dbeaaa..ee8025806f 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyViewModel.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/SurveyViewModel.kt @@ -19,8 +19,6 @@ package com.example.compose.jetsurvey.survey import android.net.Uri import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.example.compose.jetsurvey.survey.question.Superhero @@ -66,23 +64,19 @@ class SurveyViewModel( val selfieUriResponse: Uri? get() = _selfieUriResponse.value - // ----- Survey status exposed as LiveData ----- + // ----- Survey status exposed as State ----- - private val _isSurveyComplete = MutableLiveData(false) - val isSurveyComplete: LiveData - get() = _isSurveyComplete + private val _isSurveyComplete = mutableStateOf(false) + val isSurveyComplete: Boolean + get() = _isSurveyComplete.value - private val _surveyScreenData = MutableLiveData() - val surveyScreenData: LiveData - get() = _surveyScreenData + private val _surveyScreenData = mutableStateOf(createSurveyScreenData()) + val surveyScreenData: SurveyScreenData? + get() = _surveyScreenData.value - private val _isNextEnabled = MutableLiveData(false) - val isNextEnabled: LiveData - get() = _isNextEnabled - - init { - updateSurveyScreenData() - } + private val _isNextEnabled = mutableStateOf(false) + val isNextEnabled: Boolean + get() = _isNextEnabled.value /** * Returns true if the ViewModel handled the back press (i.e., it went back one question) @@ -109,7 +103,7 @@ class SurveyViewModel( private fun changeQuestion(newQuestionIndex: Int) { questionIndex = newQuestionIndex _isNextEnabled.value = getIsNextEnabled() - updateSurveyScreenData() + _surveyScreenData.value = createSurveyScreenData() } fun onDonePressed() { @@ -154,8 +148,8 @@ class SurveyViewModel( } } - private fun updateSurveyScreenData() { - _surveyScreenData.value = SurveyScreenData( + private fun createSurveyScreenData(): SurveyScreenData { + return SurveyScreenData( questionIndex = questionIndex, questionCount = questionOrder.size, shouldShowPreviousButton = questionIndex > 0, diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/DateQuestion.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/DateQuestion.kt index da135f53ae..212484551f 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/DateQuestion.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/DateQuestion.kt @@ -46,16 +46,16 @@ import java.util.TimeZone @Composable fun DateQuestion( - modifier: Modifier = Modifier, @StringRes titleResourceId: Int, - @StringRes directionResourceId: Int, + @StringRes directionsResourceId: Int, dateInMillis: Long?, onClick: () -> Unit, + modifier: Modifier = Modifier, ) { QuestionWrapper( - modifier = modifier, titleResourceId = titleResourceId, - directionResourceId = directionResourceId, + directionsResourceId = directionsResourceId, + modifier = modifier, ) { // All times are stored in UTC, so generate the display from UTC also val dateFormat = SimpleDateFormat(simpleDateFormatPattern, Locale.getDefault()) @@ -100,7 +100,7 @@ fun DateQuestionPreview() { Surface { DateQuestion( titleResourceId = R.string.takeaway, - directionResourceId = R.string.select_date, + directionsResourceId = R.string.select_date, dateInMillis = 1672560000000, // 2023-01-01 onClick = {}, ) diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/MultipleChoiceQuestion.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/MultipleChoiceQuestion.kt index 152772499c..5c60bf9ac5 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/MultipleChoiceQuestion.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/MultipleChoiceQuestion.kt @@ -41,17 +41,17 @@ import com.example.compose.jetsurvey.survey.QuestionWrapper @Composable fun MultipleChoiceQuestion( - modifier: Modifier = Modifier, @StringRes titleResourceId: Int, - @StringRes directionResourceId: Int, + @StringRes directionsResourceId: Int, possibleAnswers: List, selectedAnswers: List, onOptionSelected: (selected: Boolean, answer: Int) -> Unit, + modifier: Modifier = Modifier, ) { QuestionWrapper( modifier = modifier, titleResourceId = titleResourceId, - directionResourceId = directionResourceId, + directionsResourceId = directionsResourceId, ) { possibleAnswers.forEach { val selected = selectedAnswers.contains(it) @@ -67,10 +67,10 @@ fun MultipleChoiceQuestion( @Composable fun CheckboxRow( - modifier: Modifier = Modifier, text: String, selected: Boolean, onOptionSelected: () -> Unit, + modifier: Modifier = Modifier, ) { Surface( shape = MaterialTheme.shapes.small, @@ -112,7 +112,7 @@ fun MultipleChoiceQuestionPreview() { val selectedAnswers = remember { mutableStateListOf(R.string.work_out) } MultipleChoiceQuestion( titleResourceId = R.string.in_my_free_time, - directionResourceId = R.string.select_all, + directionsResourceId = R.string.select_all, possibleAnswers = possibleAnswers, selectedAnswers = selectedAnswers, onOptionSelected = { _, _ -> } diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/PhotoQuestion.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/PhotoQuestion.kt index 397bdcdf48..1e53733fd2 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/PhotoQuestion.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/PhotoQuestion.kt @@ -56,10 +56,10 @@ import com.example.compose.jetsurvey.theme.JetsurveyTheme @Composable fun PhotoQuestion( - modifier: Modifier = Modifier, @StringRes titleResourceId: Int, imageUri: Uri?, onClick: () -> Unit, + modifier: Modifier = Modifier, ) { val iconResource = if (imageUri != null) { Icons.Filled.SwapHoriz @@ -68,8 +68,8 @@ fun PhotoQuestion( } QuestionWrapper( - modifier = modifier, titleResourceId = titleResourceId, + modifier = modifier, ) { OutlinedButton( @@ -146,10 +146,10 @@ fun PhotoQuestionPreview() { JetsurveyTheme { Surface { PhotoQuestion( - modifier = Modifier.padding(16.dp), titleResourceId = R.string.selfie_skills, imageUri = null, onClick = {}, + modifier = Modifier.padding(16.dp), ) } } diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/SingleChoiceQuestion.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/SingleChoiceQuestion.kt index 8c757aff7e..51cfa9945c 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/SingleChoiceQuestion.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/SingleChoiceQuestion.kt @@ -51,17 +51,17 @@ import com.example.compose.jetsurvey.survey.QuestionWrapper @Composable fun SingleChoiceQuestion( - modifier: Modifier = Modifier, @StringRes titleResourceId: Int, - @StringRes directionResourceId: Int, + @StringRes directionsResourceId: Int, possibleAnswers: List, selectedAnswer: Superhero?, onOptionSelected: (Superhero) -> Unit, + modifier: Modifier = Modifier, ) { QuestionWrapper( - modifier = modifier.selectableGroup(), titleResourceId = titleResourceId, - directionResourceId = directionResourceId, + directionsResourceId = directionsResourceId, + modifier = modifier.selectableGroup(), ) { possibleAnswers.forEach { val selected = it == selectedAnswer @@ -78,11 +78,11 @@ fun SingleChoiceQuestion( @Composable fun RadioButtonWithImageRow( - modifier: Modifier = Modifier, text: String, @DrawableRes imageResourceId: Int, selected: Boolean, onOptionSelected: () -> Unit, + modifier: Modifier = Modifier, ) { Surface( shape = MaterialTheme.shapes.small, @@ -143,7 +143,7 @@ fun SingleChoiceQuestionPreview() { SingleChoiceQuestion( titleResourceId = R.string.pick_superhero, - directionResourceId = R.string.select_one, + directionsResourceId = R.string.select_one, possibleAnswers = possibleAnswers, selectedAnswer = selectedAnswer, onOptionSelected = { selectedAnswer = it }, diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/SliderQuestion.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/SliderQuestion.kt index 905b691b69..4617feeeb6 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/SliderQuestion.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/question/SliderQuestion.kt @@ -41,7 +41,6 @@ import com.example.compose.jetsurvey.theme.JetsurveyTheme @Composable fun SliderQuestion( - modifier: Modifier = Modifier, @StringRes titleResourceId: Int, value: Float?, onValueChange: (Float) -> Unit, @@ -50,13 +49,14 @@ fun SliderQuestion( @StringRes startTextResource: Int, @StringRes neutralTextResource: Int, @StringRes endTextResource: Int, + modifier: Modifier = Modifier, ) { var sliderPosition by remember { mutableStateOf(value ?: ((valueRange.endInclusive - valueRange.start) / 2)) } QuestionWrapper( - modifier = modifier, titleResourceId = titleResourceId, + modifier = modifier, ) { Row { From cef7d7f9a2c5950405feda9e3759eefe70a9ebf3 Mon Sep 17 00:00:00 2001 From: "Ian G. Clifton" Date: Tue, 3 Jan 2023 13:07:19 -0800 Subject: [PATCH 6/7] Fixed SurveyViewModelTest --- .../compose/jetsurvey/survey/SurveyViewModelTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Jetsurvey/app/src/test/java/com/example/compose/jetsurvey/survey/SurveyViewModelTest.kt b/Jetsurvey/app/src/test/java/com/example/compose/jetsurvey/survey/SurveyViewModelTest.kt index 29941883a6..b552ee85ac 100644 --- a/Jetsurvey/app/src/test/java/com/example/compose/jetsurvey/survey/SurveyViewModelTest.kt +++ b/Jetsurvey/app/src/test/java/com/example/compose/jetsurvey/survey/SurveyViewModelTest.kt @@ -42,22 +42,22 @@ class SurveyViewModelTest { // Starts empty, next disabled Truth.assertThat(viewModel.freeTimeResponse).isEmpty() - Truth.assertThat(viewModel.isNextEnabled.value).isFalse() + Truth.assertThat(viewModel.isNextEnabled).isFalse() // Add two arbitrary values viewModel.onFreeTimeResponse(true, answerOne) viewModel.onFreeTimeResponse(true, answerTwo) Truth.assertThat(viewModel.freeTimeResponse).containsExactly(answerOne, answerTwo) - Truth.assertThat(viewModel.isNextEnabled.value).isTrue() + Truth.assertThat(viewModel.isNextEnabled).isTrue() // Remove one value viewModel.onFreeTimeResponse(false, answerTwo) Truth.assertThat(viewModel.freeTimeResponse).containsExactly(answerOne) - Truth.assertThat(viewModel.isNextEnabled.value).isTrue() + Truth.assertThat(viewModel.isNextEnabled).isTrue() // Remove the last value viewModel.onFreeTimeResponse(false, answerOne) Truth.assertThat(viewModel.freeTimeResponse).isEmpty() - Truth.assertThat(viewModel.isNextEnabled.value).isFalse() + Truth.assertThat(viewModel.isNextEnabled).isFalse() } } From 711e9296a7bcd159628073c1138f061b7a1ace8b Mon Sep 17 00:00:00 2001 From: "Ian G. Clifton" Date: Wed, 4 Jan 2023 10:23:34 -0800 Subject: [PATCH 7/7] It's 2023!? --- .../com/example/compose/jetsurvey/survey/QuestionWrapper.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/QuestionWrapper.kt b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/QuestionWrapper.kt index 1c1c7f30c7..de45bf64e0 100644 --- a/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/QuestionWrapper.kt +++ b/Jetsurvey/app/src/main/java/com/example/compose/jetsurvey/survey/QuestionWrapper.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Android Open Source Project + * Copyright 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License.