// // TurnBubbleView.swift // Poche // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // import SwiftUI struct TurnBubbleView: View { let turn: ChatTurn var body: some View { switch turn.role { case .system: systemNotice case .user: HStack { Spacer(minLength: 56) Text(turn.text) .foregroundStyle(.white) .padding(.horizontal, Theme.spacingMedium + 4) .padding(.vertical, Theme.spacingMedium - 2) .background(Theme.accentGradient) .clipShape(RoundedRectangle(cornerRadius: Theme.bubbleCornerRadius)) .shadow(color: Theme.accentA.opacity(0.28), radius: 7, y: 3) } case .assistant: HStack { assistantBody .padding(.horizontal, Theme.spacingMedium + 4) .padding(.vertical, Theme.spacingMedium - 2) .background(Theme.surface) .clipShape(RoundedRectangle(cornerRadius: Theme.bubbleCornerRadius)) .shadow(color: .black.opacity(0.06), radius: 5, y: 2) Spacer(minLength: 56) } } } private var systemNotice: some View { HStack(spacing: Theme.spacingSmall) { Image(systemName: turn.text.hasPrefix("✓") ? "checkmark.circle.fill" : "info.circle") .foregroundStyle(turn.text.hasPrefix("✓") ? AnyShapeStyle(.green) : AnyShapeStyle(.secondary)) Text(turn.text.hasPrefix("✓") ? String(turn.text.dropFirst(2)) : turn.text) .foregroundStyle(.secondary) } .font(.footnote.weight(.medium)) .padding(.horizontal, Theme.spacingMedium + 2) .padding(.vertical, Theme.spacingSmall + 2) .background(.thinMaterial, in: Capsule()) .frame(maxWidth: .infinity, alignment: .center) } @ViewBuilder private var assistantBody: some View { switch turn.status { case .streaming where turn.text.isEmpty: TypingIndicator() .padding(.vertical, 4) case .refused, .failed: // Neutral tone: the conversation stays intact, rephrasing stays possible. Text(turn.text) .italic() .foregroundStyle(.secondary) default: Text(turn.text) .foregroundStyle(.primary) } } } /// Three softly pulsing dots while the first token is on its way. struct TypingIndicator: View { @State private var animating = false var body: some View { HStack(spacing: 5) { ForEach(0..<3, id: \.self) { index in Circle() .fill(Theme.accentGradient) .frame(width: 7, height: 7) .scaleEffect(animating ? 1 : 0.55) .opacity(animating ? 1 : 0.4) .animation( .easeInOut(duration: 0.5) .repeatForever(autoreverses: true) .delay(Double(index) * 0.16), value: animating ) } } .onAppear { animating = true } .accessibilityLabel("Poche écrit") } }