feat: question discussion components, conversation VM, wheel session, settings, text input limits

This commit is contained in:
null 2026-07-01 21:39:00 -05:00
parent 09fea873e2
commit 24823a39f0
12 changed files with 90 additions and 50 deletions

View File

@ -1102,6 +1102,14 @@ This is the deep home for a11y; the Pass C contrast/font spot-checks feed into i
confirm the focused field stays visible and typable (don't assume — the daily flow is choice-only, so it confirm the focused field stays visible and typable (don't assume — the daily flow is choice-only, so it
never exercises this). Input screens: auth (login/signup/forgot), onboarding/profile, pairing/invite/recovery, never exercises this). Input screens: auth (login/signup/forgot), onboarding/profile, pairing/invite/recovery,
Messages conversation, Bucket List, Date Builder, **Date Reflection**, Change/Delete/Edit in Settings, Wheel. Messages conversation, Bucket List, Date Builder, **Date Reflection**, Change/Delete/Edit in Settings, Wheel.
- **Free-text length + truncation policy (R25 UI review):** every free-text input is bounded **at entry in its
ViewModel** — the caps are centralized in `ui/components/TextInputLimits.kt` (`MESSAGE` 2000 · `DISCUSSION_MESSAGE`
500 · `WRITTEN_ANSWER` 2000; the conversation / discussion / question-detail / question-thread / wheel VMs alias
those, and chat/discussion/wheel/written-answer also `.trim()` on send). Content is bounded on the way IN, **never
truncated at display** — so the rule is **ellipsize chrome (TopAppBar titles, one-line labels/rows, pills, counts),
never content or errors.** A `maxLines`+`TextOverflow.Ellipsis` on a message/answer bubble, a question, or an error
string is a bug (it silently hides what a partner wrote). The shared written-answer field surfaces a character
counter only within `TextInputLimits.COUNTER_THRESHOLD` of the cap.
- **Font scaling:** `adb shell settings put system font_scale 1.3` (then 1.5, 2.0) — every primary flow stays usable: - **Font scaling:** `adb shell settings put system font_scale 1.3` (then 1.5, 2.0) — every primary flow stays usable:
**no clipped/overlapping text, no cut-off or hidden buttons/actions** (scroll where needed). **Acceptance: all primary **no clipped/overlapping text, no cut-off or hidden buttons/actions** (scroll where needed). **Acceptance: all primary
flows usable at increased font scale without clipped buttons or hidden actions.** Restore `font_scale 1.0` after. flows usable at increased font scale without clipped buttons or hidden actions.** Restore `font_scale 1.0` after.

View File

@ -0,0 +1,25 @@
package app.closer.ui.components
/**
* Single source of truth for the character caps on the app's free-text inputs.
*
* Every free-text input bounds its content at entry (in the owning ViewModel), so the encrypted payload,
* the Firestore doc, and render cost stay sane. Content is bounded on the way IN, never truncated at
* display time so any longer pre-existing content still renders in full. Keeping the numbers here (and
* having the ViewModels alias them) means the written-answer character counter can never drift from the
* cap it's counting toward.
*/
object TextInputLimits {
/** Main conversation chat messages. */
const val MESSAGE = 2000
/** Per-question discussion comments (a lighter-weight thread than the main chat). */
const val DISCUSSION_MESSAGE = 500
/** Free-form written answers (question detail/thread + spin-the-wheel). */
const val WRITTEN_ANSWER = 2000
/** Only surface a character counter once the user is within this many chars of the cap normal
* typing stays uncluttered, but there's clear feedback before input stops. */
const val COUNTER_THRESHOLD = 200
}

View File

@ -12,6 +12,7 @@ import app.closer.domain.repository.ConversationRepository
import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.UserRepository import app.closer.domain.repository.UserRepository
import app.closer.notifications.ActiveThreadMonitor import app.closer.notifications.ActiveThreadMonitor
import app.closer.ui.components.TextInputLimits
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -266,7 +267,7 @@ class ConversationViewModel @Inject constructor(
_uiState.update { it.copy(pendingMedia = it.pendingMedia.map { m -> if (m.id == id) m.copy(failed = true) else m }) } _uiState.update { it.copy(pendingMedia = it.pendingMedia.map { m -> if (m.id == id) m.copy(failed = true) else m }) }
companion object { companion object {
const val MAX_MESSAGE_LENGTH = 2000 const val MAX_MESSAGE_LENGTH = TextInputLimits.MESSAGE
const val PAGE_SIZE = 50 const val PAGE_SIZE = 50
private const val TYPING_TTL_MS = 6000L private const val TYPING_TTL_MS = 6000L
} }

View File

@ -77,7 +77,7 @@ fun LocalQuestionUiState.toLocalAnswer(question: Question): LocalAnswer {
questionText = question.text, questionText = question.text,
category = question.category, category = question.category,
answerType = question.type, answerType = question.type,
writtenText = pendingWrittenText.takeIf { question.type == "written" && it.isNotBlank() }, writtenText = pendingWrittenText.trim().takeIf { question.type == "written" && it.isNotBlank() },
selectedOptionIds = when (question.type) { selectedOptionIds = when (question.type) {
"single_choice", "multi_choice", "this_or_that" -> pendingSelectedOptionIds "single_choice", "multi_choice", "this_or_that" -> pendingSelectedOptionIds
else -> emptyList() else -> emptyList()

View File

@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import app.closer.domain.model.ChoiceAnswerConfigImpl import app.closer.domain.model.ChoiceAnswerConfigImpl
import app.closer.domain.repository.LocalAnswerRepository import app.closer.domain.repository.LocalAnswerRepository
import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionRepository
import app.closer.ui.components.TextInputLimits
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -88,7 +89,7 @@ class QuestionDetailViewModel @Inject constructor(
fun canSubmit(): Boolean = canSubmit(_uiState.value) fun canSubmit(): Boolean = canSubmit(_uiState.value)
companion object { companion object {
const val MAX_ANSWER_LENGTH = 2000 const val MAX_ANSWER_LENGTH = TextInputLimits.WRITTEN_ANSWER
} }
private fun canSubmit(state: LocalQuestionUiState): Boolean { private fun canSubmit(state: LocalQuestionUiState): Boolean {

View File

@ -12,6 +12,7 @@ import app.closer.domain.model.QuestionAnswer
import app.closer.domain.model.QuestionMessage import app.closer.domain.model.QuestionMessage
import app.closer.domain.model.QuestionReaction import app.closer.domain.model.QuestionReaction
import app.closer.domain.repository.QuestionThreadRepository import app.closer.domain.repository.QuestionThreadRepository
import app.closer.ui.components.TextInputLimits
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -266,7 +267,7 @@ class QuestionThreadViewModel @Inject constructor(
userId = currentUserId, userId = currentUserId,
questionId = questionId, questionId = questionId,
answerType = question.type, answerType = question.type,
writtenText = if (question.type == "written") state.pendingWrittenText else null, writtenText = if (question.type == "written") state.pendingWrittenText.trim() else null,
selectedOptionIds = when (question.type) { selectedOptionIds = when (question.type) {
"single_choice", "multi_choice", "this_or_that" -> state.pendingSelectedOptionIds "single_choice", "multi_choice", "this_or_that" -> state.pendingSelectedOptionIds
else -> emptyList() else -> emptyList()
@ -368,7 +369,7 @@ class QuestionThreadViewModel @Inject constructor(
} }
companion object { companion object {
const val MAX_ANSWER_LENGTH = 2000 const val MAX_ANSWER_LENGTH = TextInputLimits.WRITTEN_ANSWER
const val MAX_MESSAGE_LENGTH = 500 const val MAX_MESSAGE_LENGTH = TextInputLimits.DISCUSSION_MESSAGE
} }
} }

View File

@ -28,7 +28,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.closer.domain.model.Question import app.closer.domain.model.Question
import app.closer.domain.model.QuestionAnswer import app.closer.domain.model.QuestionAnswer
@ -99,9 +98,7 @@ fun AnswerBubble(
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
else else
MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)
maxLines = 5,
overflow = TextOverflow.Ellipsis
) )
} }

View File

@ -34,6 +34,7 @@ import app.closer.domain.model.Question
import app.closer.domain.model.ScaleAnswerConfigImpl import app.closer.domain.model.ScaleAnswerConfigImpl
import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.model.ThisOrThatAnswerConfigImpl
import app.closer.domain.model.WrittenAnswerConfigImpl import app.closer.domain.model.WrittenAnswerConfigImpl
import app.closer.ui.components.TextInputLimits
@Composable @Composable
fun QuestionAnswerInput( fun QuestionAnswerInput(
@ -109,33 +110,48 @@ private fun WrittenAnswerInput(
text: String, text: String,
onTextChanged: (String) -> Unit onTextChanged: (String) -> Unit
) { ) {
OutlinedTextField( Column(modifier = Modifier.fillMaxWidth()) {
value = text, OutlinedTextField(
onValueChange = onTextChanged, value = text,
modifier = Modifier onValueChange = { onTextChanged(it.take(TextInputLimits.WRITTEN_ANSWER)) },
.fillMaxWidth() modifier = Modifier
.height(148.dp), .fillMaxWidth()
placeholder = { .height(148.dp),
placeholder = {
Text(
text = config?.config?.placeholder ?: "Write your answer…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
)
},
singleLine = false,
minLines = 4,
maxLines = 6,
shape = RoundedCornerShape(14.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f),
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurface
),
textStyle = MaterialTheme.typography.bodyLarge
)
// Counter appears only as the user nears the cap — keeps the field clean during normal writing.
val remaining = TextInputLimits.WRITTEN_ANSWER - text.length
if (remaining <= TextInputLimits.COUNTER_THRESHOLD) {
Text( Text(
text = config?.config?.placeholder ?: "Write your answer…", text = "${text.length}/${TextInputLimits.WRITTEN_ANSWER}",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f) color = if (remaining <= 0) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier
.align(Alignment.End)
.padding(top = 4.dp, end = 4.dp)
) )
}, }
singleLine = false, }
minLines = 4,
maxLines = 6,
shape = RoundedCornerShape(14.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f),
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurface
),
textStyle = MaterialTheme.typography.bodyLarge
)
} }
// ─── Single choice ──────────────────────────────────────────────────────────── // ─── Single choice ────────────────────────────────────────────────────────────

View File

@ -46,7 +46,6 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
@ -179,9 +178,7 @@ private fun DiscussionMessageBubble(
MaterialTheme.colorScheme.onPrimaryContainer MaterialTheme.colorScheme.onPrimaryContainer
else else
MaterialTheme.colorScheme.onSurfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
maxLines = 10,
overflow = TextOverflow.Ellipsis
) )
} }
} }

View File

@ -13,7 +13,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import app.closer.domain.model.Question import app.closer.domain.model.Question
@ -49,9 +48,7 @@ fun QuestionHeader(
lineHeight = 34.sp lineHeight = 34.sp
), ),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center
maxLines = 6,
overflow = TextOverflow.Ellipsis
) )
} }
} }

View File

@ -158,18 +158,14 @@ fun RelationshipSettingsScreen(
Text( Text(
text = "Your relationship data stays private. Leaving unlinks you and your partner — it does not delete your account or answers.", text = "Your relationship data stays private. Leaving unlinks you and your partner — it does not delete your account or answers.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted, color = SettingsMuted
maxLines = 4,
overflow = TextOverflow.Ellipsis
) )
state.error?.let { err -> state.error?.let { err ->
Text( Text(
text = err, text = err,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = SettingsDanger, color = SettingsDanger
maxLines = 3,
overflow = TextOverflow.Ellipsis
) )
} }

View File

@ -14,6 +14,7 @@ import app.closer.domain.model.ScaleAnswerConfigImpl
import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.model.ThisOrThatAnswerConfigImpl
import app.closer.domain.repository.QuestionRepository import app.closer.domain.repository.QuestionRepository
import app.closer.domain.usecase.GameSessionManager import app.closer.domain.usecase.GameSessionManager
import app.closer.ui.components.TextInputLimits
import app.closer.ui.games.GameCopy import app.closer.ui.games.GameCopy
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
@ -173,7 +174,7 @@ class WheelSessionViewModel @Inject constructor(
} }
fun onWrittenTextChanged(text: String) { fun onWrittenTextChanged(text: String) {
_uiState.update { it.copy(writtenText = text) } _uiState.update { it.copy(writtenText = text.take(TextInputLimits.WRITTEN_ANSWER)) }
} }
/** /**