Picking an option marked nothing until the answer had crossed the tunnel, been recorded and come back as an event, so the card sat unchanged for most of a second after a tap. What the reader has picked is now the card's own state and shows at once; a Submit at the foot sends every question the tool is waiting on, greyed until all of them have an answer and a spinner while the request is out. Several questions are paged rather than stacked, with the count and a pair of arrows on the right, because three questions with four described options each is several screens and the reader scrolls past the one they are answering to reach the button that sends it. A permission ask keeps its single tap -- two bare words are not worth a submit step -- and marks what was pressed until the request settles, so the mark either stands on the recorded answer or goes away with the failure. Chevron draws all four directions from one description of the shape, since the pager needed two more of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
403 lines
18 KiB
Kotlin
403 lines
18 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.BorderStroke
|
|
import androidx.compose.foundation.horizontalScroll
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.FlowRow
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.ButtonDefaults
|
|
import androidx.compose.material3.CardDefaults
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.LocalContentColor
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedButton
|
|
import androidx.compose.material3.OutlinedCard
|
|
import androidx.compose.material3.OutlinedTextField
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.unit.dp
|
|
|
|
/** One question's answer on its way back, so a card can hand over several at once. */
|
|
data class QuestionAnswer(val questionId: String, val answers: List<String>)
|
|
|
|
/**
|
|
* What the reader has settled on for one question, before any of it is sent.
|
|
*
|
|
* Held here rather than inferred from the transcript, which is what made picking an option feel
|
|
* broken: the mark used to appear only when the answer had crossed the tunnel, been recorded and
|
|
* come back as an event, so on a phone the card sat unchanged for most of a second after a tap and
|
|
* the natural response was to tap again.
|
|
*
|
|
* Picked options and typed words are one field each because they are alternatives rather than
|
|
* parts: answering in the reader's own words is the case no option covers, so typing puts the picks
|
|
* away and picking puts the words away, and there is never a draft that means two things.
|
|
*/
|
|
data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
|
|
val settled: Boolean
|
|
get() = picked.isNotEmpty() || other.isNotBlank()
|
|
|
|
/**
|
|
* What goes back, in the order the options were offered rather than the order they were tapped:
|
|
* the reader is answering a list, and it should read back as that list.
|
|
*/
|
|
fun answers(options: List<QuestionOption>): List<String> =
|
|
if (other.isNotBlank()) listOf(other.trim())
|
|
else options.map { it.label }.filter { it in picked }
|
|
}
|
|
|
|
/**
|
|
* Every question one tool call is waiting on, one at a time.
|
|
*
|
|
* All of it comes from the question events themselves -- what each option means, what picking it
|
|
* would produce, whether several may be picked at once. None of it is read out of the call's own
|
|
* input, which is one provider's JSON: parsing that here would put that provider's schema in the
|
|
* app, where no other provider can reach it and where it drifts the first time the schema moves.
|
|
*
|
|
* One question on screen with arrows to the others, rather than all of them stacked. A card asking
|
|
* three questions with four options and a description each is several screens tall, so the reader
|
|
* scrolls past the question they are answering to reach the button that sends it, and never sees
|
|
* the whole of any one of them. Paged, each question is a screen and the count says how many are
|
|
* left -- which is also what makes "not all of them are answered" something the reader can act on
|
|
* rather than something to go hunting for.
|
|
*
|
|
* Nothing is sent until Submit. Answering is one act even when it is several questions: the tool
|
|
* asked them together and is waiting on all of them, and sending each as it was tapped meant the
|
|
* reader could not change their mind about the first after reading the third.
|
|
*/
|
|
@Composable
|
|
fun AskUserQuestionBody(
|
|
asks: List<TranscriptItem.QuestionCard>,
|
|
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
|
|
) {
|
|
// Seeded from what was already answered, so a card the reader comes back to shows their
|
|
// answers rather than an empty draft over them.
|
|
var drafts by
|
|
remember(asks.map { it.id }) {
|
|
mutableStateOf(
|
|
asks.associate { ask ->
|
|
ask.id to
|
|
Draft(
|
|
picked =
|
|
ask.answers
|
|
.filter { a -> ask.options.any { it.label == a } }
|
|
.toSet(),
|
|
other =
|
|
ask.answers
|
|
.firstOrNull { a -> ask.options.none { it.label == a } }
|
|
.orEmpty(),
|
|
)
|
|
}
|
|
)
|
|
}
|
|
var at by remember(asks.map { it.id }) { mutableIntStateOf(0) }
|
|
var sending by remember(asks.map { it.id }) { mutableStateOf(false) }
|
|
if (asks.isEmpty()) return
|
|
val showing = asks[at.coerceIn(0, asks.size - 1)]
|
|
val outstanding = asks.filter { it.answers.isEmpty() }
|
|
|
|
Column(Modifier.fillMaxWidth()) {
|
|
if (asks.size > 1) {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Text(
|
|
"Question ${at + 1} of ${asks.size}",
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.weight(1f),
|
|
)
|
|
// Disabled at the ends rather than absent, so the pair keeps its place and the
|
|
// reader can see that there is nothing further that way.
|
|
MarkButton("Previous question", { at-- }, enabled = at > 0) {
|
|
Chevron(Pointing.Left, colour = LocalContentColor.current)
|
|
}
|
|
MarkButton("Next question", { at++ }, enabled = at < asks.size - 1) {
|
|
Chevron(Pointing.Right, colour = LocalContentColor.current)
|
|
}
|
|
}
|
|
}
|
|
Spacer(Modifier.height(4.dp))
|
|
AskedQuestion(
|
|
showing,
|
|
draft = drafts[showing.id] ?: Draft(),
|
|
onDraft = { drafts = drafts + (showing.id to it) },
|
|
)
|
|
if (outstanding.isNotEmpty()) {
|
|
Spacer(Modifier.height(12.dp))
|
|
// Greyed until every question has an answer, because the tool is waiting on all of
|
|
// them: a submit that sent two of three would leave the third one asked and the card
|
|
// looking dealt with.
|
|
val ready = outstanding.all { drafts[it.id]?.settled == true }
|
|
Button(
|
|
onClick = {
|
|
sending = true
|
|
onAnswer(
|
|
outstanding.map { ask ->
|
|
QuestionAnswer(ask.id, (drafts[ask.id] ?: Draft()).answers(ask.options))
|
|
}
|
|
) {
|
|
// Back to a button whatever happened. A refusal is reported by the screen
|
|
// around this, and the draft is still here to send again -- a spinner
|
|
// that never stops would be the only sign of a failure this card cannot
|
|
// describe.
|
|
sending = false
|
|
}
|
|
},
|
|
enabled = ready && !sending,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
if (sending) {
|
|
// In the button rather than beside it, so the row does not change height at
|
|
// the moment it is pressed.
|
|
CircularProgressIndicator(
|
|
Modifier.height(18.dp).width(18.dp),
|
|
strokeWidth = 2.dp,
|
|
color = LocalContentColor.current,
|
|
)
|
|
} else {
|
|
Text(
|
|
if (outstanding.size > 1) "Submit ${outstanding.size} answers" else "Submit"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One question: what is being asked, what can be answered, and what was.
|
|
*
|
|
* The same body wherever a question appears -- on the call that asked it, or as a card of its own
|
|
* when nothing did. A question is the same thing either way, and two renderings of it would be two
|
|
* places for an answer to go missing.
|
|
*
|
|
* [draft] is what the reader has picked so far and [onDraft] is how they change it; nothing here
|
|
* sends anything. An answered question ignores both and draws what was answered.
|
|
*/
|
|
@Composable
|
|
fun AskedQuestion(
|
|
ask: TranscriptItem.QuestionCard,
|
|
draft: Draft,
|
|
onDraft: (Draft) -> Unit,
|
|
) {
|
|
Column(Modifier.fillMaxWidth()) {
|
|
ask.header?.let { header ->
|
|
// Its own line rather than beside the question, because it is a label *for* the
|
|
// question and the question is the thing to read.
|
|
Text(
|
|
header.uppercase(),
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
}
|
|
Text(ask.prompt, style = MaterialTheme.typography.bodyLarge)
|
|
Spacer(Modifier.height(8.dp))
|
|
// An answered question keeps its options and marks the one that was taken, rather than
|
|
// replacing them with a line repeating it. The options are what the question *was*, and
|
|
// dropping them leaves an answer with nothing to have been an answer to -- "Sonnet" says
|
|
// very little without the three it was chosen over. Marked in the same purple that says
|
|
// "picked" while the question is still open, so it is one appearance learned once.
|
|
val answered = ask.answers.isNotEmpty()
|
|
// What is marked: what was answered once there is an answer, and what the finger has
|
|
// chosen until then.
|
|
val marked = if (answered) ask.answers.toSet() else draft.picked
|
|
// Null once the question is answered: the options stay and stop being pressable.
|
|
val onPick: ((String) -> Unit)? =
|
|
if (answered) null else { label -> onDraft(pick(draft, label, ask.multiSelect)) }
|
|
if (ask.options.all { it.description == null && it.preview == null }) {
|
|
// Nothing to read, so nothing to lay out: Allow and Deny are two words, and two words
|
|
// do not need a card each.
|
|
AnswerOptions(ask.options, marked.toList(), onPick)
|
|
} else {
|
|
ask.options.forEach { option ->
|
|
OptionCard(option, selected = option.label in marked) {
|
|
onPick?.invoke(option.label)
|
|
}
|
|
}
|
|
}
|
|
// What was answered in the reader's own words, which no option can mark -- see
|
|
// [OtherAnswer]. Only ever the answers that match nothing offered, so a question answered
|
|
// by picking says it by the mark alone.
|
|
val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } }
|
|
if (inWords.isNotEmpty()) {
|
|
Text(
|
|
"Answered: ${inWords.joinToString(", ")}",
|
|
style = MaterialTheme.typography.labelLarge,
|
|
color = MaterialTheme.colorScheme.primary,
|
|
modifier = Modifier.padding(top = 8.dp),
|
|
)
|
|
}
|
|
if (!answered) {
|
|
OtherAnswer(draft.other) { onDraft(Draft(other = it)) }
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* [label] added to, or taken out of, what [draft] has picked.
|
|
*
|
|
* A single-answer question replaces rather than accumulates, and either way picking puts any typed
|
|
* words away -- see [Draft].
|
|
*/
|
|
private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
|
|
when {
|
|
!multiSelect -> Draft(picked = setOf(label))
|
|
label in draft.picked -> Draft(picked = draft.picked - label)
|
|
else -> Draft(picked = draft.picked + label)
|
|
}
|
|
|
|
/**
|
|
* One option: what it is called, what it means, and what it would produce.
|
|
*
|
|
* Outlined rather than tinted. Drawn first as a card one step up the surface ladder, it was
|
|
* indistinguishable from the card behind it -- three paragraphs of text where three things to press
|
|
* should have been, which is the failure a tint step routinely produces on a dark theme. A border
|
|
* is one cue and it is unambiguous.
|
|
*/
|
|
@Composable
|
|
private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) {
|
|
OutlinedCard(
|
|
onClick = onPick,
|
|
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
|
colors =
|
|
CardDefaults.outlinedCardColors(
|
|
containerColor =
|
|
if (selected) MaterialTheme.colorScheme.primaryContainer
|
|
else MaterialTheme.colorScheme.surface
|
|
),
|
|
// Picked shows in the border as well as the fill, because the fill alone is a colour
|
|
// difference somebody has to have seen the unpicked version to notice.
|
|
border =
|
|
BorderStroke(
|
|
if (selected) 2.dp else 1.dp,
|
|
if (selected) MaterialTheme.colorScheme.primary
|
|
else MaterialTheme.colorScheme.outlineVariant,
|
|
),
|
|
) {
|
|
Column(Modifier.padding(12.dp)) {
|
|
Text(option.label, style = MaterialTheme.typography.titleSmall)
|
|
option.description?.let {
|
|
Text(
|
|
it,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
modifier = Modifier.padding(top = 2.dp),
|
|
)
|
|
}
|
|
option.preview?.let { Preview(it) }
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An option's worked example, shown as written.
|
|
*
|
|
* On its own surface, because it is a different kind of thing from the sentence above it: that
|
|
* describes the option, this is a sample of what the option produces, and monospace alone reads as
|
|
* a description that happens to be in code font.
|
|
*/
|
|
@Composable
|
|
private fun Preview(preview: String) {
|
|
Surface(
|
|
color = MaterialTheme.colorScheme.surfaceContainerLowest,
|
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
|
) {
|
|
Text(
|
|
preview,
|
|
style = MaterialTheme.typography.bodySmall,
|
|
fontFamily = FontFamily.Monospace,
|
|
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines
|
|
// of the thing being previewed.
|
|
softWrap = false,
|
|
modifier = Modifier.padding(8.dp).horizontalScroll(rememberScrollState()),
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The choice the asker always leaves open, and the app has to as well.
|
|
*
|
|
* Every AskUserQuestion carries an implicit "Other" -- the reader may answer in their own words
|
|
* rather than pick. Leaving it out narrows a question that was never that narrow, and the reader
|
|
* cannot tell that it was ever open.
|
|
*/
|
|
@Composable
|
|
private fun OtherAnswer(text: String, onText: (String) -> Unit) {
|
|
// No Send of its own: this is one more way to answer the question, and the card's Submit is
|
|
// what sends it. A second send button beside the field made the shorter half of the card look
|
|
// like the one that finishes it.
|
|
OutlinedTextField(
|
|
value = text,
|
|
onValueChange = onText,
|
|
label = { Text("Other") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Bare options, wrapped rather than in a row.
|
|
*
|
|
* A Row hands out intrinsic widths in order and clips whatever runs past the edge, so a question
|
|
* with four options showed the first one or two and dropped the rest off the side of the screen.
|
|
* That does not read as a bug: it reads as those having been the only choices, which is the worst
|
|
* way for a list of choices to be wrong.
|
|
*/
|
|
@Composable
|
|
fun AnswerOptions(
|
|
options: List<QuestionOption>,
|
|
/** What is chosen: the answer once there is one, and what the finger has marked until then. */
|
|
answers: List<String> = emptyList(),
|
|
/** Null once the question is answered -- the buttons stay, and stop being buttons. */
|
|
onPick: ((String) -> Unit)?,
|
|
) {
|
|
FlowRow(
|
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
options.forEach { option ->
|
|
val taken = option.label in answers
|
|
OutlinedButton(
|
|
onClick = { onPick?.invoke(option.label) },
|
|
// Disabled rather than removed, so an answered question still shows what it
|
|
// offered. Material dims a disabled button's own border and label, which would
|
|
// take the mark with it -- both are stated here instead.
|
|
enabled = onPick != null,
|
|
border =
|
|
BorderStroke(
|
|
if (taken) 2.dp else 1.dp,
|
|
if (taken) MaterialTheme.colorScheme.primary
|
|
else MaterialTheme.colorScheme.outlineVariant,
|
|
),
|
|
colors =
|
|
ButtonDefaults.outlinedButtonColors(
|
|
disabledContentColor =
|
|
if (taken) MaterialTheme.colorScheme.primary
|
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
|
),
|
|
) {
|
|
Text(option.label)
|
|
}
|
|
}
|
|
}
|
|
}
|