diff --git a/app/src/main/java/com/dano/test1/questionnaire/QuestionnaireAllInOne.kt b/app/src/main/java/com/dano/test1/questionnaire/QuestionnaireAllInOne.kt new file mode 100644 index 0000000..f1d5f24 --- /dev/null +++ b/app/src/main/java/com/dano/test1/questionnaire/QuestionnaireAllInOne.kt @@ -0,0 +1,522 @@ +package com.dano.test1.questionnaire + +import android.graphics.Color +import android.text.Editable +import android.text.TextWatcher +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.constraintlayout.widget.ConstraintLayout +import com.dano.test1.LanguageManager +import com.dano.test1.LocalizationHelper +import com.dano.test1.MainActivity +import com.dano.test1.R +import com.dano.test1.network.TokenStore +import com.dano.test1.utils.ViewUtils +import com.google.android.material.button.MaterialButton +import com.google.android.material.card.MaterialCardView +import kotlinx.coroutines.* + +class QuestionnaireAllInOne(private val questionnaireFileName: String) : QuestionnaireBase() { + + private data class Section( + val index: Int, + val question: QuestionItem, + val card: MaterialCardView, + val sectionView: View, + val handler: QuestionHandler? + ) + + private val sections = mutableListOf
() + private lateinit var container: LinearLayout + private lateinit var btnSave: MaterialButton + private var setupComplete = false + + override fun startQuestionnaire() { + val (meta, questionsList) = loadQuestionnaireFromJson(questionnaireFileName) + questionnaireMeta = meta + questions = questionsList + currentIndex = 0 + buildAllInOneUi() + } + + override fun showCurrentQuestion() { + // Not used in all-in-one mode + } + + private fun buildAllInOneUi() { + context.setContentView(R.layout.questionnaire_all_in_one) + + container = context.findViewById(R.id.questionContainer) + btnSave = context.findViewById(R.id.btnSave) + val btnBack = context.findViewById(R.id.btnBack) + val progressBar = context.findViewById(R.id.progressBar) + + btnSave.text = LanguageManager.getText(languageID, "save") + ViewUtils.setTextSizePercentOfScreenHeight(btnSave, 0.025f) + + btnSave.isEnabled = false + btnSave.alpha = 0.5f + + btnBack.setOnClickListener { + (context as? MainActivity)?.finishQuestionnaire() + } + + val inflater = LayoutInflater.from(context) + + for ((idx, question) in questions.withIndex()) { + if (question is QuestionItem.LastPage) continue + + val layoutResId = getLayoutResId(question.layout ?: "default_layout") + if (layoutResId == 0) continue + + val sectionView = inflater.inflate(layoutResId, container, false) + adaptLayoutForEmbedding(sectionView) + LocalizationHelper.localizeViewTree(sectionView, languageID) + + val card = wrapInCard(sectionView) + container.addView(card) + + val handler = createEmbeddedHandler(question) + handler?.bind(sectionView, question) + reduceTextSizes(sectionView) + + sections.add(Section(idx, question, card, sectionView, handler)) + } + + installChangeListeners() + recalculateVisibility() + + // Allow DB restores to finish before enabling interaction tracking + container.postDelayed({ + setupComplete = true + recalculateVisibility() + updateSaveButtonState() + }, 600) + + btnSave.setOnClickListener { + handleSave(progressBar) + } + } + + private fun createEmbeddedHandler(question: QuestionItem): QuestionHandler? { + val noop = {} + val noopId: (String) -> Unit = {} + val toast: (String) -> Unit = { showToast(it) } + val metaId = questionnaireMeta.id + + return when (question) { + is QuestionItem.RadioQuestion -> + com.dano.test1.questionnaire.handlers.HandlerRadioQuestion( + context, answers, points, languageID, noop, noop, noopId, toast, metaId + ) + is QuestionItem.ClientCoachCodeQuestion -> + com.dano.test1.questionnaire.handlers.HandlerClientCoachCode( + answers, languageID, noop, noop, toast + ) + is QuestionItem.DateSpinnerQuestion -> + com.dano.test1.questionnaire.handlers.HandlerDateSpinner( + context, answers, languageID, noop, noop, toast, metaId + ) + is QuestionItem.ValueSpinnerQuestion -> + com.dano.test1.questionnaire.handlers.HandlerValueSpinner( + context, answers, languageID, noop, noop, noopId, toast, metaId + ) + is QuestionItem.GlassScaleQuestion -> + com.dano.test1.questionnaire.handlers.HandlerGlassScaleQuestion( + context, answers, points, languageID, noop, noop, toast, metaId + ) + is QuestionItem.ClientNotSigned -> + com.dano.test1.questionnaire.handlers.HandlerClientNotSigned( + answers, languageID, noop, noop, toast + ) + is QuestionItem.StringSpinnerQuestion -> + com.dano.test1.questionnaire.handlers.HandlerStringSpinner( + context, answers, languageID, noop, noop, toast, metaId + ) + is QuestionItem.MultiCheckboxQuestion -> + com.dano.test1.questionnaire.handlers.HandlerMultiCheckboxQuestion( + context, answers, points, languageID, noop, noop, toast, metaId + ) + else -> null + } + } + + private fun adaptLayoutForEmbedding(view: View) { + if (view is ConstraintLayout) { + view.layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + // Ensure the last field in the card is never clipped at the bottom + view.setPadding( + view.paddingLeft, view.paddingTop, + view.paddingRight, ViewUtils.dp(context, 12) + ) + } + + view.findViewById(R.id.Qprev)?.visibility = View.GONE + view.findViewById(R.id.Qnext)?.visibility = View.GONE + + view.findViewById(R.id.gTop)?.let { guideline -> + if (guideline is androidx.constraintlayout.widget.Guideline) { + val params = guideline.layoutParams as? ConstraintLayout.LayoutParams + params?.guideBegin = ViewUtils.dp(context, 8) + guideline.layoutParams = params + } + } + + adaptInnerScrollView(view, R.id.radioScroll) + adaptInnerScrollView(view, R.id.scrollView) + adaptInnerScrollView(view, R.id.glassScroll) + + view.findViewById(R.id.client_code)?.let { et -> + val params = et.layoutParams as? ConstraintLayout.LayoutParams ?: return@let + if (params.matchConstraintPercentHeight > 0f) { + params.matchConstraintPercentHeight = 0f + params.height = ViewUtils.dp(context, 64) + et.layoutParams = params + } + } + view.findViewById(R.id.coach_code)?.let { et -> + val params = et.layoutParams as? ConstraintLayout.LayoutParams ?: return@let + if (params.matchConstraintPercentHeight > 0f) { + params.matchConstraintPercentHeight = 0f + params.height = ViewUtils.dp(context, 64) + et.layoutParams = params + } + } + } + + /** + * Scales down all TextView text sizes by [factor] after the handler has set them. + * Spinners manage their own adapter text sizes and are skipped. + */ + private fun reduceTextSizes(view: View, factor: Float = 0.82f) { + if (view is Spinner) return + if (view is TextView) { + val dm = view.context.resources.displayMetrics + val currentSp = view.textSize / dm.scaledDensity + view.setTextSize(TypedValue.COMPLEX_UNIT_SP, currentSp * factor) + } + if (view is ViewGroup) { + for (i in 0 until view.childCount) reduceTextSizes(view.getChildAt(i), factor) + } + } + + private fun adaptInnerScrollView(root: View, scrollViewId: Int) { + val sv = root.findViewById(scrollViewId) ?: return + val params = sv.layoutParams + if (params is ConstraintLayout.LayoutParams) { + params.height = ConstraintLayout.LayoutParams.WRAP_CONTENT + params.bottomToTop = ConstraintLayout.LayoutParams.UNSET + params.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID + sv.layoutParams = params + } + sv.isNestedScrollingEnabled = false + } + + private fun wrapInCard(sectionView: View): MaterialCardView { + val card = MaterialCardView(context).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + val margin = ViewUtils.dp(context, 6) + setMargins(0, margin, 0, margin) + } + radius = ViewUtils.dp(context, 16).toFloat() + cardElevation = ViewUtils.dp(context, 2).toFloat() + setCardBackgroundColor(Color.WHITE) + strokeColor = Color.parseColor("#D8D1F0") + strokeWidth = ViewUtils.dp(context, 1) + setContentPadding( + ViewUtils.dp(context, 4), + ViewUtils.dp(context, 8), + ViewUtils.dp(context, 4), + ViewUtils.dp(context, 12) + ) + } + card.addView(sectionView) + return card + } + + // ---- Visibility walk algorithm (reads from UI state, not answers map) ---- + + private fun recalculateVisibility() { + val visibleIndices = mutableSetOf() + var i = 0 + + while (i < questions.size) { + val q = questions[i] + if (q is QuestionItem.LastPage) { i++; continue } + + visibleIndices.add(i) + + val nextTarget = resolveNextFromUi(q) + when { + nextTarget != null -> { + if (questions.any { it is QuestionItem.LastPage && it.id == nextTarget }) { + break + } + val targetIdx = questions.indexOfFirst { it.id == nextTarget } + i = if (targetIdx != -1) targetIdx else i + 1 + } + hasBranchingOptions(q) && !hasAnswerInUi(q) -> break + else -> i++ + } + } + + for (section in sections) { + val shouldShow = visibleIndices.contains(section.index) + section.card.visibility = if (shouldShow) View.VISIBLE else View.GONE + } + } + + private fun resolveNextFromUi(q: QuestionItem): String? { + val section = sections.find { it.question === q } ?: return null + val view = section.sectionView + + return when (q) { + is QuestionItem.RadioQuestion -> { + val radioGroup = view.findViewById(R.id.RadioGroup) ?: return null + val checkedId = radioGroup.checkedRadioButtonId + if (checkedId == -1) return null + val rb = radioGroup.findViewById(checkedId) ?: return null + val key = rb.tag?.toString() ?: return null + q.options?.find { it.key == key }?.nextQuestionId + } + is QuestionItem.ValueSpinnerQuestion -> { + val spinner = view.findViewById(R.id.value_spinner) ?: return null + val selected = spinner.selectedItem?.toString() ?: return null + val prompt = LanguageManager.getText(languageID, "choose_answer") + if (selected == prompt) return null + val selectedVal = selected.toIntOrNull() + if (selectedVal != null) { + q.options?.find { it.value == selectedVal }?.nextQuestionId + } else null + } + else -> null + } + } + + private fun hasBranchingOptions(q: QuestionItem): Boolean { + return when (q) { + is QuestionItem.RadioQuestion -> + q.options?.any { it.nextQuestionId != null } ?: false + is QuestionItem.ValueSpinnerQuestion -> + q.options?.any { it.nextQuestionId != null } ?: false + else -> false + } + } + + private fun hasAnswerInUi(q: QuestionItem): Boolean { + val section = sections.find { it.question === q } ?: return false + val view = section.sectionView + + return when (q) { + is QuestionItem.RadioQuestion -> { + val rg = view.findViewById(R.id.RadioGroup) ?: return false + rg.checkedRadioButtonId != -1 + } + is QuestionItem.ValueSpinnerQuestion -> { + val spinner = view.findViewById(R.id.value_spinner) ?: return false + val selected = spinner.selectedItem?.toString() + val prompt = LanguageManager.getText(languageID, "choose_answer") + !selected.isNullOrEmpty() && selected != prompt + } + else -> true + } + } + + // ---- Change listeners ---- + + private fun installChangeListeners() { + for (section in sections) { + val view = section.sectionView + + when (section.question) { + is QuestionItem.RadioQuestion -> { + view.findViewById(R.id.RadioGroup) + ?.setOnCheckedChangeListener { _, checkedId -> + if (!setupComplete || checkedId == -1) return@setOnCheckedChangeListener + recalculateVisibility() + updateSaveButtonState() + } + } + is QuestionItem.ValueSpinnerQuestion -> { + installSpinnerChangeListener(view, R.id.value_spinner) + } + is QuestionItem.StringSpinnerQuestion -> { + installSpinnerChangeListener(view, R.id.string_spinner) + } + is QuestionItem.DateSpinnerQuestion -> { + val listener = createSimpleSpinnerListener() + view.findViewById(R.id.spinner_value_day)?.onItemSelectedListener = listener + view.findViewById(R.id.spinner_value_month)?.onItemSelectedListener = listener + view.findViewById(R.id.spinner_value_year)?.onItemSelectedListener = listener + } + is QuestionItem.GlassScaleQuestion -> { + installGlassScaleClickListeners(view) + } + is QuestionItem.MultiCheckboxQuestion -> { + installCheckboxClickListeners(view) + } + is QuestionItem.ClientCoachCodeQuestion -> { + installEditTextWatcher(view, R.id.client_code) + installEditTextWatcher(view, R.id.coach_code) + } + is QuestionItem.ClientNotSigned -> { + installEditTextWatcher(view, R.id.coach_code) + } + else -> {} + } + } + } + + private fun installSpinnerChangeListener(view: View, spinnerId: Int) { + view.findViewById(spinnerId)?.onItemSelectedListener = + object : AdapterView.OnItemSelectedListener { + override fun onItemSelected( + parent: AdapterView<*>?, v: View?, pos: Int, id: Long + ) { + if (!setupComplete) return + recalculateVisibility() + updateSaveButtonState() + } + override fun onNothingSelected(parent: AdapterView<*>?) {} + } + } + + private fun createSimpleSpinnerListener(): AdapterView.OnItemSelectedListener { + return object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, v: View?, pos: Int, id: Long) { + if (!setupComplete) return + updateSaveButtonState() + } + override fun onNothingSelected(parent: AdapterView<*>?) {} + } + } + + private fun installGlassScaleClickListeners(view: View) { + val table = view.findViewById(R.id.glass_table) ?: return + for (r in 0 until table.childCount) { + val row = table.getChildAt(r) as? TableRow ?: continue + val radioGroup = row.getChildAt(1) as? RadioGroup ?: continue + for (c in 0 until radioGroup.childCount) { + val rb = getRadioFromChild(radioGroup.getChildAt(c)) ?: continue + rb.setOnClickListener { + if (setupComplete) updateSaveButtonState() + } + } + } + } + + private fun getRadioFromChild(child: View): RadioButton? = when (child) { + is RadioButton -> child + is FrameLayout -> child.getChildAt(0) as? RadioButton + else -> null + } + + private fun installCheckboxClickListeners(view: View) { + val cont = view.findViewById(R.id.CheckboxContainer) ?: return + for (i in 0 until cont.childCount) { + val cb = cont.getChildAt(i) as? CheckBox ?: continue + cb.setOnClickListener { + if (setupComplete) updateSaveButtonState() + } + } + } + + private fun installEditTextWatcher(view: View, editTextId: Int) { + view.findViewById(editTextId)?.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable?) { + if (!setupComplete) return + updateSaveButtonState() + } + }) + } + + // ---- Save button state ---- + + private fun updateSaveButtonState() { + val allValid = sections + .filter { it.card.visibility == View.VISIBLE } + .all { section -> + val handler = section.handler ?: return@all true + try { handler.validate() } catch (_: Exception) { false } + } + btnSave.isEnabled = allValid + btnSave.alpha = if (allValid) 1f else 0.5f + } + + // ---- Save flow ---- + + private fun handleSave(progressBar: ProgressBar) { + val visibleSections = sections.filter { it.card.visibility == View.VISIBLE } + + for (section in visibleSections) { + val handler = section.handler ?: continue + if (!handler.validate()) { + showToast(LanguageManager.getText(languageID, "fill_all_fields")) + scrollToSection(section) + return + } + } + + for (section in visibleSections) { + if (section.question is QuestionItem.ClientCoachCodeQuestion) { + saveClientCoachCodeFromUi(section.sectionView) + } + section.handler?.saveAnswer() + } + + showLoading(progressBar, true) + + CoroutineScope(Dispatchers.IO).launch { + val startTime = System.currentTimeMillis() + + saveAnswersToDatabase(answers, questionnaireMeta.id) + + GlobalValues.INTEGRATION_INDEX = points.sum() + val clientCode = answers["client_code"] as? String + if (clientCode != null) { + GlobalValues.LAST_CLIENT_CODE = clientCode + GlobalValues.LOADED_CLIENT_CODE = clientCode + } + + val elapsed = System.currentTimeMillis() - startTime + if (elapsed < 1500L) delay(1500L - elapsed) + + withContext(Dispatchers.Main) { + showLoading(progressBar, false) + endQuestionnaire() + } + } + } + + private fun saveClientCoachCodeFromUi(view: View) { + val clientCode = view.findViewById(R.id.client_code)?.text?.toString() ?: "" + val coachCode = view.findViewById(R.id.coach_code)?.text?.toString() ?: "" + GlobalValues.LAST_CLIENT_CODE = clientCode + answers["client_code"] = clientCode + answers["coach_code"] = TokenStore.getUsername(context) ?: coachCode + } + + private fun showLoading(progressBar: ProgressBar, show: Boolean) { + progressBar.visibility = if (show) View.VISIBLE else View.GONE + btnSave.isEnabled = !show + btnSave.alpha = if (show) 0.5f else 1f + } + + private fun scrollToSection(section: Section) { + val scrollView = context.findViewById(R.id.scrollContainer) + scrollView?.post { + scrollView.smoothScrollTo(0, section.card.top) + } + } +} diff --git a/app/src/main/java/com/dano/test1/questionnaire/QuestionnaireProgressiveCallbacks.kt b/app/src/main/java/com/dano/test1/questionnaire/QuestionnaireProgressiveCallbacks.kt new file mode 100644 index 0000000..8c029dc --- /dev/null +++ b/app/src/main/java/com/dano/test1/questionnaire/QuestionnaireProgressiveCallbacks.kt @@ -0,0 +1,9 @@ +package com.dano.test1.questionnaire + +object QuestionnaireProgressiveCallbacks { + @JvmStatic + fun maybeTrim(questionId: String?) { + // Intentional no-op: handlers call this during user interaction, + // but trimming is handled by each questionnaire variant itself. + } +} diff --git a/app/src/main/java/com/dano/test1/ui/HandlerOpeningScreen.kt b/app/src/main/java/com/dano/test1/ui/HandlerOpeningScreen.kt index 5f18ad6..234b81b 100644 --- a/app/src/main/java/com/dano/test1/ui/HandlerOpeningScreen.kt +++ b/app/src/main/java/com/dano/test1/ui/HandlerOpeningScreen.kt @@ -20,6 +20,7 @@ import com.dano.test1.network.TokenStore import com.dano.test1.questionnaire.GlobalValues import com.dano.test1.questionnaire.QuestionItem import com.dano.test1.questionnaire.QuestionnaireBase +import com.dano.test1.questionnaire.QuestionnaireAllInOne import com.dano.test1.questionnaire.QuestionnaireGeneric import com.google.android.material.button.MaterialButton import org.json.JSONArray @@ -225,7 +226,12 @@ class HandlerOpeningScreen(private val activity: MainActivity) { setOnClickListener { GlobalValues.LAST_CLIENT_CODE = GlobalValues.LOADED_CLIENT_CODE val fileName = questionnaireFiles[this] ?: return@setOnClickListener - val questionnaire = QuestionnaireGeneric(fileName) + val variant = AbTestSettingsStore.effectiveVariant(activity) + val questionnaire: QuestionnaireBase<*> = if (variant == "B") { + QuestionnaireAllInOne(fileName) + } else { + QuestionnaireGeneric(fileName) + } startQuestionnaire(questionnaire) applySetButtonsEnabled(dynamicButtons.filter { it == this }, allowCompleted = false, force = false) } diff --git a/app/src/main/res/layout/questionnaire_all_in_one.xml b/app/src/main/res/layout/questionnaire_all_in_one.xml new file mode 100644 index 0000000..aa52995 --- /dev/null +++ b/app/src/main/res/layout/questionnaire_all_in_one.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + +