This repository has been archived on 2026-10-04. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
hope-voice-ios/Sources/Features/Conversation/LightwellView.swift
T

123 lines
4.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import UIKit
/// The Nearlight signature element: an upright, softly lit aperture.
///
/// Two capsules in a ZStack — no assets, shaders, blur, or particles. The
/// inner capsule carries a vertical Rosewood gradient; its geometry encodes
/// conversation state and audio level. Noninteractive and accessibility
/// hidden: the companion status text carries meaning.
struct LightwellView: View {
/// Lightwell presentation state, mapped from session/conversation models.
enum Phase: Equatable {
case idle
case connecting
case waiting
case listening(level: Double)
case thinking
case speaking(level: Double)
case tearingDown
case failed
/// Input never drives the light (muted or Type mode).
case readyNoInput
}
let phase: Phase
/// Compact (24×36) in the conversation status strip; full (64×96) on welcome.
var compact: Bool = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Pending-state pulse driver, 0 → 1 → 0 over the 2.4s cycle.
@State private var pulse: Double = 0
/// Frame dimensions never animate; inner dimensions multiply by 0.375
/// in the compact frame.
private var frameSize: CGSize {
compact ? CGSize(width: 24, height: 36) : CGSize(width: 64, height: 96)
}
private var baseInner: CGSize {
let base = CGSize(width: 16, height: 40)
return compact
? CGSize(width: base.width * 0.375, height: base.height * 0.375)
: base
}
var body: some View {
let inner = innerSize
ZStack {
Capsule()
.fill(Nearlight.surface)
Capsule()
.fill(
LinearGradient(
stops: [
.init(color: Nearlight.rosewood.opacity(0.35), location: 0),
.init(color: Nearlight.rosewood.opacity(1), location: 0.5),
.init(color: Nearlight.rosewood.opacity(0.55), location: 1),
],
startPoint: .top, endPoint: .bottom
)
)
.frame(width: inner.width, height: inner.height)
.opacity(overallOpacity)
.animation(reduceMotion ? nil : Nearlight.quick, value: overallOpacity)
.animation(reduceMotion ? nil : Nearlight.settle, value: innerSize)
}
.frame(width: frameSize.width, height: frameSize.height)
.overlay(
Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1)
)
.accessibilityHidden(true)
.task(id: pulseActive) {
guard pulseActive else {
pulse = 0
return
}
while !Task.isCancelled {
withAnimation(reduceMotion ? nil : Nearlight.cycle) { pulse = pulse == 0 ? 1 : 0 }
try? await Task.sleep(for: .seconds(2.4))
}
}
}
/// Whether the pulse loop should run for this phase.
private var pulseActive: Bool {
switch phase {
case .connecting, .waiting, .thinking: return !reduceMotion
default: return false
}
}
/// Inner capsule geometry per spec table. `a` is the smoothed level.
private var innerSize: CGSize {
let base = baseInner
switch phase {
case .listening(let a):
let level = min(max(a, 0), 1)
return CGSize(width: base.width + (compact ? 3 : 8) * level,
height: base.height + (compact ? 6 : 16) * level)
case .speaking(let a):
let level = min(max(a, 0), 1)
return CGSize(width: base.width + (compact ? 4.5 : 12) * level,
height: base.height + (compact ? 9 : 24) * level)
case .waiting, .thinking:
// Pulse drives opacity; height steps up one band.
return CGSize(width: base.width, height: base.height + (compact ? 3 : 8))
case .connecting, .idle, .failed, .tearingDown, .readyNoInput:
return base
}
}
private var overallOpacity: Double {
switch phase {
case .connecting, .waiting:
return 0.45 + 0.45 * pulse
case .thinking:
return 0.65 + 0.35 * pulse
case .idle, .failed, .tearingDown:
return 0.45
case .listening, .speaking, .readyNoInput:
return 1
}
}
}