feat: question discussion components, conversation VM, wheel session, settings, text input limits
This commit is contained in:
parent
09fea873e2
commit
24823a39f0
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue