feat(node): show camera capture HUD
This commit is contained in:
@@ -7,6 +7,13 @@ import UIKit
|
||||
@MainActor
|
||||
@Observable
|
||||
final class NodeAppModel {
|
||||
enum CameraHUDKind {
|
||||
case photo
|
||||
case recording
|
||||
case success
|
||||
case error
|
||||
}
|
||||
|
||||
var isBackgrounded: Bool = false
|
||||
let screen = ScreenController()
|
||||
let camera = CameraController()
|
||||
@@ -18,10 +25,15 @@ final class NodeAppModel {
|
||||
private let bridge = BridgeSession()
|
||||
private var bridgeTask: Task<Void, Never>?
|
||||
private var voiceWakeSyncTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
|
||||
let voiceWake = VoiceWakeManager()
|
||||
|
||||
var bridgeSession: BridgeSession { self.bridge }
|
||||
|
||||
var cameraHUDText: String?
|
||||
var cameraHUDKind: CameraHUDKind?
|
||||
var cameraFlashNonce: Int = 0
|
||||
|
||||
init() {
|
||||
self.voiceWake.configure { [weak self] cmd in
|
||||
guard let self else { return }
|
||||
@@ -453,6 +465,8 @@ final class NodeAppModel {
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: resultJSON)
|
||||
|
||||
case ClawdisCameraCommand.snap.rawValue:
|
||||
self.showCameraHUD(text: "Taking photo…", kind: .photo)
|
||||
self.triggerCameraFlash()
|
||||
let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ??
|
||||
ClawdisCameraSnapParams()
|
||||
let res = try await self.camera.snap(params: params)
|
||||
@@ -468,6 +482,7 @@ final class NodeAppModel {
|
||||
base64: res.base64,
|
||||
width: res.width,
|
||||
height: res.height))
|
||||
self.showCameraHUD(text: "Photo captured", kind: .success, autoHideSeconds: 1.6)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
|
||||
case ClawdisCameraCommand.clip.rawValue:
|
||||
@@ -477,6 +492,7 @@ final class NodeAppModel {
|
||||
let suspended = (params.includeAudio ?? true) ? self.voiceWake.suspendForExternalAudioCapture() : false
|
||||
defer { self.voiceWake.resumeAfterExternalAudioCapture(wasSuspended: suspended) }
|
||||
|
||||
self.showCameraHUD(text: "Recording…", kind: .recording)
|
||||
let res = try await self.camera.clip(params: params)
|
||||
|
||||
struct Payload: Codable {
|
||||
@@ -490,6 +506,7 @@ final class NodeAppModel {
|
||||
base64: res.base64,
|
||||
durationMs: res.durationMs,
|
||||
hasAudio: res.hasAudio))
|
||||
self.showCameraHUD(text: "Clip captured", kind: .success, autoHideSeconds: 1.8)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
|
||||
default:
|
||||
@@ -499,6 +516,10 @@ final class NodeAppModel {
|
||||
error: ClawdisNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
|
||||
}
|
||||
} catch {
|
||||
if command.hasPrefix("camera.") {
|
||||
let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
self.showCameraHUD(text: text, kind: .error, autoHideSeconds: 2.2)
|
||||
}
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
@@ -530,4 +551,26 @@ final class NodeAppModel {
|
||||
if UserDefaults.standard.object(forKey: "camera.enabled") == nil { return true }
|
||||
return UserDefaults.standard.bool(forKey: "camera.enabled")
|
||||
}
|
||||
|
||||
private func triggerCameraFlash() {
|
||||
self.cameraFlashNonce &+= 1
|
||||
}
|
||||
|
||||
private func showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
|
||||
self.cameraHUDDismissTask?.cancel()
|
||||
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
|
||||
self.cameraHUDText = text
|
||||
self.cameraHUDKind = kind
|
||||
}
|
||||
|
||||
guard let autoHideSeconds else { return }
|
||||
self.cameraHUDDismissTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: UInt64(autoHideSeconds * 1_000_000_000))
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
self.cameraHUDText = nil
|
||||
self.cameraHUDKind = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ struct RootCanvas: View {
|
||||
bridgeStatus: self.bridgeStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
voiceWakeToastText: self.voiceWakeToastText,
|
||||
cameraHUDText: self.appModel.cameraHUDText,
|
||||
cameraHUDKind: self.appModel.cameraHUDKind,
|
||||
openChat: {
|
||||
self.presentedSheet = .chat
|
||||
},
|
||||
@@ -38,6 +40,10 @@ struct RootCanvas: View {
|
||||
self.presentedSheet = .settings
|
||||
})
|
||||
.preferredColorScheme(.dark)
|
||||
|
||||
if self.appModel.cameraFlashNonce != 0 {
|
||||
CameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
|
||||
}
|
||||
}
|
||||
.sheet(item: self.$presentedSheet) { sheet in
|
||||
switch sheet {
|
||||
@@ -103,6 +109,8 @@ private struct CanvasContent: View {
|
||||
var bridgeStatus: StatusPill.BridgeState
|
||||
var voiceWakeEnabled: Bool
|
||||
var voiceWakeToastText: String?
|
||||
var cameraHUDText: String?
|
||||
var cameraHUDKind: NodeAppModel.CameraHUDKind?
|
||||
var openChat: () -> Void
|
||||
var openSettings: () -> Void
|
||||
|
||||
@@ -147,6 +155,32 @@ private struct CanvasContent: View {
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
|
||||
CameraCaptureToast(
|
||||
text: cameraHUDText,
|
||||
kind: self.mapCameraKind(cameraHUDKind),
|
||||
brighten: self.brightenButtons)
|
||||
.padding(SwiftUI.Edge.Set.leading, 10)
|
||||
.safeAreaPadding(SwiftUI.Edge.Set.top, 106)
|
||||
.transition(
|
||||
AnyTransition.move(edge: SwiftUI.Edge.top)
|
||||
.combined(with: AnyTransition.opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func mapCameraKind(_ kind: NodeAppModel.CameraHUDKind) -> CameraCaptureToast.Kind {
|
||||
switch kind {
|
||||
case .photo:
|
||||
.photo
|
||||
case .recording:
|
||||
.recording
|
||||
case .success:
|
||||
.success
|
||||
case .error:
|
||||
.error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,3 +221,85 @@ private struct OverlayButton: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CameraFlashOverlay: View {
|
||||
var nonce: Int
|
||||
|
||||
@State private var opacity: CGFloat = 0
|
||||
@State private var task: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
Color.white
|
||||
.opacity(self.opacity)
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
.onChange(of: self.nonce) { _, _ in
|
||||
self.task?.cancel()
|
||||
self.task = Task { @MainActor in
|
||||
withAnimation(.easeOut(duration: 0.08)) {
|
||||
self.opacity = 0.85
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 110_000_000)
|
||||
withAnimation(.easeOut(duration: 0.32)) {
|
||||
self.opacity = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CameraCaptureToast: View {
|
||||
enum Kind {
|
||||
case photo
|
||||
case recording
|
||||
case success
|
||||
case error
|
||||
}
|
||||
|
||||
var text: String
|
||||
var kind: Kind
|
||||
var brighten: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
self.icon
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(self.text)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 12)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
}
|
||||
.accessibilityLabel("Camera")
|
||||
.accessibilityValue(self.text)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var icon: some View {
|
||||
switch self.kind {
|
||||
case .photo:
|
||||
Image(systemName: "camera.fill")
|
||||
case .recording:
|
||||
Image(systemName: "record.circle.fill")
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(.red, .primary)
|
||||
case .success:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user