diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/CameraHudState.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/CameraHudState.kt new file mode 100644 index 000000000..f33e147fb --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/CameraHudState.kt @@ -0,0 +1,15 @@ +package com.steipete.clawdis.node + +enum class CameraHudKind { + Photo, + Recording, + Success, + Error, +} + +data class CameraHudState( + val token: Long, + val kind: CameraHudKind, + val message: String, +) + diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt index 6d60215d1..216a37aee 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt @@ -22,6 +22,9 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val serverName: StateFlow = runtime.serverName val remoteAddress: StateFlow = runtime.remoteAddress + val cameraHud: StateFlow = runtime.cameraHud + val cameraFlashToken: StateFlow = runtime.cameraFlashToken + val instanceId: StateFlow = runtime.instanceId val displayName: StateFlow = runtime.displayName val cameraEnabled: StateFlow = runtime.cameraEnabled diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt index 1e8ea08d1..6ebef6e2d 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt @@ -4,6 +4,7 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.os.Build +import android.os.SystemClock import androidx.core.content.ContextCompat import com.steipete.clawdis.node.chat.ChatController import com.steipete.clawdis.node.chat.ChatMessage @@ -41,6 +42,7 @@ import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject +import java.util.concurrent.atomic.AtomicLong class NodeRuntime(context: Context) { private val appContext = context.applicationContext @@ -88,6 +90,13 @@ class NodeRuntime(context: Context) { private val _statusText = MutableStateFlow("Offline") val statusText: StateFlow = _statusText.asStateFlow() + private val cameraHudSeq = AtomicLong(0) + private val _cameraHud = MutableStateFlow(null) + val cameraHud: StateFlow = _cameraHud.asStateFlow() + + private val _cameraFlashToken = MutableStateFlow(0L) + val cameraFlashToken: StateFlow = _cameraFlashToken.asStateFlow() + private val _serverName = MutableStateFlow(null) val serverName: StateFlow = _serverName.asStateFlow() @@ -603,14 +612,33 @@ class NodeRuntime(context: Context) { BridgeSession.InvokeResult.ok(res) } ClawdisCameraCommand.Snap.rawValue -> { - val res = camera.snap(paramsJson) + showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo) + triggerCameraFlash() + val res = + try { + camera.snap(paramsJson) + } catch (err: Throwable) { + val (code, message) = invokeErrorFromThrowable(err) + showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2200) + return BridgeSession.InvokeResult.error(code = code, message = message) + } + showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600) BridgeSession.InvokeResult.ok(res.payloadJson) } ClawdisCameraCommand.Clip.rawValue -> { val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false if (includeAudio) externalAudioCaptureActive.value = true try { - val res = camera.clip(paramsJson) + showCameraHud(message = "Recording…", kind = CameraHudKind.Recording) + val res = + try { + camera.clip(paramsJson) + } catch (err: Throwable) { + val (code, message) = invokeErrorFromThrowable(err) + showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2400) + return BridgeSession.InvokeResult.error(code = code, message = message) + } + showCameraHud(message = "Clip captured", kind = CameraHudKind.Success, autoHideMs = 1800) BridgeSession.InvokeResult.ok(res.payloadJson) } finally { if (includeAudio) externalAudioCaptureActive.value = false @@ -624,6 +652,35 @@ class NodeRuntime(context: Context) { } } + private fun triggerCameraFlash() { + // Token is used as a pulse trigger; value doesn't matter as long as it changes. + _cameraFlashToken.value = SystemClock.elapsedRealtimeNanos() + } + + private fun showCameraHud(message: String, kind: CameraHudKind, autoHideMs: Long? = null) { + val token = cameraHudSeq.incrementAndGet() + _cameraHud.value = CameraHudState(token = token, kind = kind, message = message) + + if (autoHideMs != null && autoHideMs > 0) { + scope.launch { + delay(autoHideMs) + if (_cameraHud.value?.token == token) _cameraHud.value = null + } + } + } + + private fun invokeErrorFromThrowable(err: Throwable): Pair { + val raw = (err.message ?: "").trim() + if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: camera error" + + val idx = raw.indexOf(':') + if (idx <= 0) return "UNAVAILABLE" to raw + val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" } + val message = raw.substring(idx + 1).trim().ifEmpty { raw } + // Preserve full string for callers/logging, but keep the returned message human-friendly. + return code to "$code: $message" + } + private suspend fun ensureA2uiReady(): Boolean { try { val already = canvas.eval(a2uiReadyCheckJS) diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/CameraHudOverlay.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/CameraHudOverlay.kt new file mode 100644 index 000000000..b205929cd --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/CameraHudOverlay.kt @@ -0,0 +1,123 @@ +package com.steipete.clawdis.node.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.FiberManualRecord +import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.steipete.clawdis.node.CameraHudKind +import com.steipete.clawdis.node.CameraHudState +import kotlinx.coroutines.delay + +@Composable +fun CameraHudOverlay( + hud: CameraHudState?, + flashToken: Long, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize()) { + CameraFlash(token = flashToken) + + AnimatedVisibility( + visible = hud != null, + enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it / 2 }) + fadeOut(), + modifier = Modifier.align(Alignment.TopStart).statusBarsPadding().padding(start = 12.dp, top = 58.dp), + ) { + if (hud != null) { + Toast(hud = hud) + } + } + } +} + +@Composable +private fun CameraFlash(token: Long) { + var alpha by remember { mutableFloatStateOf(0f) } + LaunchedEffect(token) { + if (token == 0L) return@LaunchedEffect + alpha = 0.85f + delay(110) + alpha = 0f + } + + Box( + modifier = + Modifier + .fillMaxSize() + .alpha(alpha) + .background(Color.White), + ) +} + +@Composable +private fun Toast(hud: CameraHudState) { + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), + tonalElevation = 2.dp, + shadowElevation = 8.dp, + ) { + Row( + modifier = Modifier.padding(vertical = 10.dp, horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when (hud.kind) { + CameraHudKind.Photo -> { + Icon(Icons.Default.PhotoCamera, contentDescription = null) + Spacer(Modifier.size(10.dp)) + CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp) + } + CameraHudKind.Recording -> { + Icon(Icons.Default.FiberManualRecord, contentDescription = null, tint = Color.Red) + } + CameraHudKind.Success -> { + Icon(Icons.Default.CheckCircle, contentDescription = null) + } + CameraHudKind.Error -> { + Icon(Icons.Default.Error, contentDescription = null) + } + } + + Spacer(Modifier.size(10.dp)) + Text( + text = hud.message, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt index 6fffcc1ed..04f693813 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/RootScreen.kt @@ -58,6 +58,8 @@ fun RootScreen(viewModel: MainViewModel) { val context = LocalContext.current val serverName by viewModel.serverName.collectAsState() val statusText by viewModel.statusText.collectAsState() + val cameraHud by viewModel.cameraHud.collectAsState() + val cameraFlashToken by viewModel.cameraFlashToken.collectAsState() val bridgeState = remember(serverName, statusText) { @@ -78,6 +80,11 @@ fun RootScreen(viewModel: MainViewModel) { CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize()) } + // Camera HUD (flash + toast) must be in a Popup to render above the WebView. + Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) { + CameraHudOverlay(hud = cameraHud, flashToken = cameraFlashToken, modifier = Modifier.fillMaxSize()) + } + // Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches. Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) { StatusPill( diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 49e3af0bd..fea87a022 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -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? private var voiceWakeSyncTask: Task? + @ObservationIgnored private var cameraHUDDismissTask: Task? 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 + } + } + } } diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 681f175ac..d0689a223 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -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? + + 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") + } + } +}