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
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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.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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,9 +110,10 @@ private fun WrittenAnswerInput(
|
||||||
text: String,
|
text: String,
|
||||||
onTextChanged: (String) -> Unit
|
onTextChanged: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = onTextChanged,
|
onValueChange = { onTextChanged(it.take(TextInputLimits.WRITTEN_ANSWER)) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(148.dp),
|
.height(148.dp),
|
||||||
|
|
@ -136,6 +138,20 @@ private fun WrittenAnswerInput(
|
||||||
),
|
),
|
||||||
textStyle = MaterialTheme.typography.bodyLarge
|
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.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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Single choice ────────────────────────────────────────────────────────────
|
// ─── Single choice ────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue