Files
hapi/ios/Hapi/Features/Chat/Blocks/QuestionDetailsView.swift
T

108 lines
4.7 KiB
Swift

import HapiUI
import SwiftUI
/// Static answer cards, never the actionable permission form. Indices identify
/// rows so malformed duplicate question IDs cannot crash SwiftUI reconciliation.
struct QuestionDetailsView: View {
let questions: [QuestionDetail]
@Environment(\.hapiTheme) private var theme
@Environment(\.hapiTypography) private var typography
var body: some View {
VStack(alignment: .leading, spacing: 18) {
ForEach(Array(questions.enumerated()), id: \.offset) { index, question in
VStack(alignment: .leading, spacing: 8) {
if let header = question.header {
Text(verbatim: header).font(typography.captionFont).foregroundStyle(theme.textSecondary)
}
if !question.question.isEmpty {
QuestionMarkdown(text: question.question)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(theme.quoteBackground, in: RoundedRectangle(cornerRadius: 8))
}
ForEach(Array(question.options.enumerated()), id: \.offset) { optionIndex, option in
QuestionAnswerCard(
text: option.label, description: option.description, markdown: true,
selected: option.selected, multiple: question.multiple, showControl: question.hasAnswers
)
.accessibilityIdentifier("question-\(index)-option-\(optionIndex)")
}
ForEach(Array(question.otherAnswers.enumerated()), id: \.offset) { _, answer in
QuestionAnswerCard(
text: answer, caption: question.options.isEmpty ? nil : String(localized: "Custom answer"),
selected: true, multiple: question.multiple
)
}
if let note = question.note {
QuestionAnswerCard(
text: note, caption: question.options.isEmpty ? nil : String(localized: "Note"),
selected: true, multiple: false
)
}
}
}
}
}
}
private struct QuestionAnswerCard: View {
let text: String
var description: String?
var caption: String?
var markdown = false
let selected: Bool
let multiple: Bool
var showControl = true
@Environment(\.hapiTheme) private var theme
@Environment(\.hapiTypography) private var typography
var body: some View {
HStack(alignment: .top, spacing: 8) {
if showControl {
Image(systemName: multiple
? (selected ? "checkmark.square.fill" : "square")
: (selected ? "largecircle.fill.circle" : "circle"))
.foregroundStyle(selected ? theme.success : theme.textHint)
.font(typography.bodyFont)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 4) {
if let caption {
Text(verbatim: caption).font(typography.captionFont).foregroundStyle(theme.textSecondary)
}
if markdown {
QuestionOptionLabel(label: text)
} else if text.count > toolTextPageSize {
ToolTextContent(language: nil, code: text)
} else {
Text(verbatim: text.isEmpty ? String(localized: "(empty)") : text)
.font(typography.bodyFont).textSelection(.enabled)
}
if let description, !description.isEmpty {
QuestionMarkdown(text: description).foregroundStyle(theme.textSecondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(10)
.background(selected ? theme.success.opacity(0.10) : theme.surface,
in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(selected ? theme.success : theme.divider, lineWidth: 1))
.accessibilityElement(children: .combine)
.accessibilityValue(showControl ? (selected ? String(localized: "Selected") : String(localized: "Not selected")) : "")
.accessibilityAddTraits(selected ? .isSelected : [])
}
}
struct QuestionMarkdown: View {
let text: String
var body: some View {
if text.count > toolTextPageSize {
ToolTextContent(language: "markdown", code: text)
} else {
CachedMarkdownView(markdown: text)
}
}
}