diff --git a/ClaudeQAPlan.md b/ClaudeQAPlan.md index 5254d7fc..10332487 100644 --- a/ClaudeQAPlan.md +++ b/ClaudeQAPlan.md @@ -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 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. +- **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: **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. diff --git a/app/src/main/java/app/closer/ui/components/TextInputLimits.kt b/app/src/main/java/app/closer/ui/components/TextInputLimits.kt new file mode 100644 index 00000000..adb2712f --- /dev/null +++ b/app/src/main/java/app/closer/ui/components/TextInputLimits.kt @@ -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 +} diff --git a/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt b/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt index 88b8b56d..8c053a3a 100644 --- a/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt +++ b/app/src/main/java/app/closer/ui/messages/ConversationViewModel.kt @@ -12,6 +12,7 @@ import app.closer.domain.repository.ConversationRepository import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.UserRepository import app.closer.notifications.ActiveThreadMonitor +import app.closer.ui.components.TextInputLimits import com.google.firebase.auth.FirebaseAuth import dagger.hilt.android.lifecycle.HiltViewModel 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 }) } companion object { - const val MAX_MESSAGE_LENGTH = 2000 + const val MAX_MESSAGE_LENGTH = TextInputLimits.MESSAGE const val PAGE_SIZE = 50 private const val TYPING_TTL_MS = 6000L } diff --git a/app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt b/app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt index c44c53ec..4833b5bc 100644 --- a/app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt +++ b/app/src/main/java/app/closer/ui/questions/LocalAnswerMapping.kt @@ -77,7 +77,7 @@ fun LocalQuestionUiState.toLocalAnswer(question: Question): LocalAnswer { questionText = question.text, category = question.category, answerType = question.type, - writtenText = pendingWrittenText.takeIf { question.type == "written" && it.isNotBlank() }, + writtenText = pendingWrittenText.trim().takeIf { question.type == "written" && it.isNotBlank() }, selectedOptionIds = when (question.type) { "single_choice", "multi_choice", "this_or_that" -> pendingSelectedOptionIds else -> emptyList() diff --git a/app/src/main/java/app/closer/ui/questions/QuestionDetailViewModel.kt b/app/src/main/java/app/closer/ui/questions/QuestionDetailViewModel.kt index ff257ebc..f27c63ef 100644 --- a/app/src/main/java/app/closer/ui/questions/QuestionDetailViewModel.kt +++ b/app/src/main/java/app/closer/ui/questions/QuestionDetailViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import app.closer.domain.model.ChoiceAnswerConfigImpl import app.closer.domain.repository.LocalAnswerRepository import app.closer.domain.repository.QuestionRepository +import app.closer.ui.components.TextInputLimits import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -88,7 +89,7 @@ class QuestionDetailViewModel @Inject constructor( fun canSubmit(): Boolean = canSubmit(_uiState.value) companion object { - const val MAX_ANSWER_LENGTH = 2000 + const val MAX_ANSWER_LENGTH = TextInputLimits.WRITTEN_ANSWER } private fun canSubmit(state: LocalQuestionUiState): Boolean { diff --git a/app/src/main/java/app/closer/ui/questions/QuestionThreadViewModel.kt b/app/src/main/java/app/closer/ui/questions/QuestionThreadViewModel.kt index 471f4a84..be9882cd 100644 --- a/app/src/main/java/app/closer/ui/questions/QuestionThreadViewModel.kt +++ b/app/src/main/java/app/closer/ui/questions/QuestionThreadViewModel.kt @@ -12,6 +12,7 @@ import app.closer.domain.model.QuestionAnswer import app.closer.domain.model.QuestionMessage import app.closer.domain.model.QuestionReaction import app.closer.domain.repository.QuestionThreadRepository +import app.closer.ui.components.TextInputLimits import com.google.firebase.auth.FirebaseAuth import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -266,7 +267,7 @@ class QuestionThreadViewModel @Inject constructor( userId = currentUserId, questionId = questionId, 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) { "single_choice", "multi_choice", "this_or_that" -> state.pendingSelectedOptionIds else -> emptyList() @@ -368,7 +369,7 @@ class QuestionThreadViewModel @Inject constructor( } companion object { - const val MAX_ANSWER_LENGTH = 2000 - const val MAX_MESSAGE_LENGTH = 500 + const val MAX_ANSWER_LENGTH = TextInputLimits.WRITTEN_ANSWER + const val MAX_MESSAGE_LENGTH = TextInputLimits.DISCUSSION_MESSAGE } } diff --git a/app/src/main/java/app/closer/ui/questions/components/AnswerBubble.kt b/app/src/main/java/app/closer/ui/questions/components/AnswerBubble.kt index 41c5e46d..aaf88bec 100644 --- a/app/src/main/java/app/closer/ui/questions/components/AnswerBubble.kt +++ b/app/src/main/java/app/closer/ui/questions/components/AnswerBubble.kt @@ -28,7 +28,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import app.closer.domain.model.Question import app.closer.domain.model.QuestionAnswer @@ -99,9 +98,7 @@ fun AnswerBubble( MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), - maxLines = 5, - overflow = TextOverflow.Ellipsis + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp) ) } diff --git a/app/src/main/java/app/closer/ui/questions/components/QuestionAnswerInput.kt b/app/src/main/java/app/closer/ui/questions/components/QuestionAnswerInput.kt index 11fac187..e8f6bd14 100644 --- a/app/src/main/java/app/closer/ui/questions/components/QuestionAnswerInput.kt +++ b/app/src/main/java/app/closer/ui/questions/components/QuestionAnswerInput.kt @@ -34,6 +34,7 @@ import app.closer.domain.model.Question import app.closer.domain.model.ScaleAnswerConfigImpl import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.model.WrittenAnswerConfigImpl +import app.closer.ui.components.TextInputLimits @Composable fun QuestionAnswerInput( @@ -109,33 +110,48 @@ private fun WrittenAnswerInput( text: String, onTextChanged: (String) -> Unit ) { - OutlinedTextField( - value = text, - onValueChange = onTextChanged, - modifier = Modifier - .fillMaxWidth() - .height(148.dp), - placeholder = { + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = text, + onValueChange = { onTextChanged(it.take(TextInputLimits.WRITTEN_ANSWER)) }, + modifier = Modifier + .fillMaxWidth() + .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 = config?.config?.placeholder ?: "Write your answer…", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f) + text = "${text.length}/${TextInputLimits.WRITTEN_ANSWER}", + style = MaterialTheme.typography.labelSmall, + 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 ──────────────────────────────────────────────────────────── diff --git a/app/src/main/java/app/closer/ui/questions/components/QuestionDiscussionThread.kt b/app/src/main/java/app/closer/ui/questions/components/QuestionDiscussionThread.kt index d2154e43..ff76d1a7 100644 --- a/app/src/main/java/app/closer/ui/questions/components/QuestionDiscussionThread.kt +++ b/app/src/main/java/app/closer/ui/questions/components/QuestionDiscussionThread.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider @@ -179,9 +178,7 @@ private fun DiscussionMessageBubble( MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - maxLines = 10, - overflow = TextOverflow.Ellipsis + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) ) } } diff --git a/app/src/main/java/app/closer/ui/questions/components/QuestionHeader.kt b/app/src/main/java/app/closer/ui/questions/components/QuestionHeader.kt index ea54c43b..a2b46afa 100644 --- a/app/src/main/java/app/closer/ui/questions/components/QuestionHeader.kt +++ b/app/src/main/java/app/closer/ui/questions/components/QuestionHeader.kt @@ -13,7 +13,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight 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.sp import app.closer.domain.model.Question @@ -49,9 +48,7 @@ fun QuestionHeader( lineHeight = 34.sp ), color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - maxLines = 6, - overflow = TextOverflow.Ellipsis + textAlign = TextAlign.Center ) } } diff --git a/app/src/main/java/app/closer/ui/settings/RelationshipSettingsScreen.kt b/app/src/main/java/app/closer/ui/settings/RelationshipSettingsScreen.kt index 001772f8..c5898ca2 100644 --- a/app/src/main/java/app/closer/ui/settings/RelationshipSettingsScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/RelationshipSettingsScreen.kt @@ -158,18 +158,14 @@ fun RelationshipSettingsScreen( Text( text = "Your relationship data stays private. Leaving unlinks you and your partner — it does not delete your account or answers.", style = MaterialTheme.typography.bodyMedium, - color = SettingsMuted, - maxLines = 4, - overflow = TextOverflow.Ellipsis + color = SettingsMuted ) state.error?.let { err -> Text( text = err, style = MaterialTheme.typography.bodySmall, - color = SettingsDanger, - maxLines = 3, - overflow = TextOverflow.Ellipsis + color = SettingsDanger ) } diff --git a/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt b/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt index a80ef9a9..528282e1 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelSessionViewModel.kt @@ -14,6 +14,7 @@ import app.closer.domain.model.ScaleAnswerConfigImpl import app.closer.domain.model.ThisOrThatAnswerConfigImpl import app.closer.domain.repository.QuestionRepository import app.closer.domain.usecase.GameSessionManager +import app.closer.ui.components.TextInputLimits import app.closer.ui.games.GameCopy import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -173,7 +174,7 @@ class WheelSessionViewModel @Inject constructor( } fun onWrittenTextChanged(text: String) { - _uiState.update { it.copy(writtenText = text) } + _uiState.update { it.copy(writtenText = text.take(TextInputLimits.WRITTEN_ANSWER)) } } /**