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 index b205929cd..2e1fec0d9 100644 --- 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 @@ -1,64 +1,26 @@ 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, +fun CameraFlashOverlay( + token: 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) - } - } + CameraFlash(token = token) } } @@ -80,44 +42,3 @@ private fun CameraFlash(token: Long) { .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 49bbee928..f3cfb4b67 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 @@ -32,6 +32,10 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChatBubble +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.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -47,6 +51,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.core.content.ContextCompat +import com.steipete.clawdis.node.CameraHudKind import com.steipete.clawdis.node.MainViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -60,6 +65,39 @@ fun RootScreen(viewModel: MainViewModel) { val statusText by viewModel.statusText.collectAsState() val cameraHud by viewModel.cameraHud.collectAsState() val cameraFlashToken by viewModel.cameraFlashToken.collectAsState() + val activity = + remember(cameraHud) { + cameraHud?.let { hud -> + when (hud.kind) { + CameraHudKind.Photo -> + StatusActivity( + title = hud.message, + icon = Icons.Default.PhotoCamera, + contentDescription = "Taking photo", + ) + CameraHudKind.Recording -> + StatusActivity( + title = hud.message, + icon = Icons.Default.FiberManualRecord, + contentDescription = "Recording", + tint = androidx.compose.ui.graphics.Color.Red, + ) + CameraHudKind.Success -> + StatusActivity( + title = hud.message, + icon = Icons.Default.CheckCircle, + contentDescription = "Capture finished", + ) + CameraHudKind.Error -> + StatusActivity( + title = hud.message, + icon = Icons.Default.Error, + contentDescription = "Capture failed", + tint = androidx.compose.ui.graphics.Color.Red, + ) + } + } + } val bridgeState = remember(serverName, statusText) { @@ -80,9 +118,9 @@ fun RootScreen(viewModel: MainViewModel) { CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize()) } - // Camera HUD (flash + toast) must be in a Popup to render above the WebView. + // Camera flash 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()) + CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize()) } // Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches. @@ -90,6 +128,7 @@ fun RootScreen(viewModel: MainViewModel) { StatusPill( bridge = bridgeState, voiceEnabled = voiceEnabled, + activity = activity, onClick = { sheet = Sheet.Settings }, modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp), ) diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/StatusPill.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/StatusPill.kt index 87a500265..2efcccae7 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/StatusPill.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/StatusPill.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.dp fun StatusPill( bridge: BridgeState, voiceEnabled: Boolean, + activity: StatusActivity? = null, onClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -62,23 +63,49 @@ fun StatusPill( color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Icon( - imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff, - contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled", - tint = - if (voiceEnabled) { - overlayIconColor() - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.size(18.dp), - ) + if (activity != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = activity.icon, + contentDescription = activity.contentDescription, + tint = activity.tint ?: overlayIconColor(), + modifier = Modifier.size(18.dp), + ) + Text( + text = activity.title, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + ) + } + } else { + Icon( + imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff, + contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled", + tint = + if (voiceEnabled) { + overlayIconColor() + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp), + ) + } Spacer(modifier = Modifier.width(2.dp)) } } } +data class StatusActivity( + val title: String, + val icon: androidx.compose.ui.graphics.vector.ImageVector, + val contentDescription: String, + val tint: Color? = null, +) + enum class BridgeState(val title: String, val color: Color) { Connected("Connected", Color(0xFF2ECC71)), Connecting("Connecting…", Color(0xFFF1C40F)), diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift index 9a5fb0b76..4d552618e 100644 --- a/apps/ios/Sources/RootCanvas.swift +++ b/apps/ios/Sources/RootCanvas.swift @@ -152,6 +152,7 @@ private struct CanvasContent: View { StatusPill( bridge: self.bridgeStatus, voiceWakeEnabled: self.voiceWakeEnabled, + activity: self.statusActivity, brighten: self.brightenButtons, onTap: { self.openSettings() @@ -169,33 +170,30 @@ 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 { + private var statusActivity: StatusPill.Activity? { + guard let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind else { return nil } + let systemImage: String + let tint: Color? + switch cameraHUDKind { case .photo: - .photo + systemImage = "camera.fill" + tint = nil case .recording: - .recording + systemImage = "video.fill" + tint = .red case .success: - .success + systemImage = "checkmark.circle.fill" + tint = .green case .error: - .error + systemImage = "exclamationmark.triangle.fill" + tint = .red } + + return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint) } + } private struct OverlayButton: View { @@ -261,59 +259,3 @@ private struct CameraFlashOverlay: View { } } } - -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") - } - } -} diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index dc2508895..913073d4a 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -26,6 +26,7 @@ struct RootTabs: View { StatusPill( bridge: self.bridgeStatus, voiceWakeEnabled: self.voiceWakeEnabled, + activity: nil, onTap: { self.selectedTab = 2 }) .padding(.leading, 10) .safeAreaPadding(.top, 10) diff --git a/apps/ios/Sources/Status/StatusPill.swift b/apps/ios/Sources/Status/StatusPill.swift index 9d3c6f6d6..f5df8e7df 100644 --- a/apps/ios/Sources/Status/StatusPill.swift +++ b/apps/ios/Sources/Status/StatusPill.swift @@ -28,8 +28,15 @@ struct StatusPill: View { } } + struct Activity: Equatable { + var title: String + var systemImage: String + var tint: Color? = nil + } + var bridge: BridgeState var voiceWakeEnabled: Bool + var activity: Activity? = nil var brighten: Bool = false var onTap: () -> Void @@ -54,10 +61,24 @@ struct StatusPill: View { .frame(height: 14) .opacity(0.35) - Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary) - .accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled") + if let activity { + HStack(spacing: 6) { + Image(systemName: activity.systemImage) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(activity.tint ?? .primary) + Text(activity.title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } else { + Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary) + .accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled") + .transition(.opacity.combined(with: .move(edge: .top))) + } } .padding(.vertical, 8) .padding(.horizontal, 12) @@ -73,7 +94,7 @@ struct StatusPill: View { } .buttonStyle(.plain) .accessibilityLabel("Status") - .accessibilityValue("\(self.bridge.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")") + .accessibilityValue(self.accessibilityValue) .onAppear { self.updatePulse(for: self.bridge, scenePhase: self.scenePhase) } .onDisappear { self.pulse = false } .onChange(of: self.bridge) { _, newValue in @@ -82,6 +103,14 @@ struct StatusPill: View { .onChange(of: self.scenePhase) { _, newValue in self.updatePulse(for: self.bridge, scenePhase: newValue) } + .animation(.easeInOut(duration: 0.18), value: self.activity?.title) + } + + private var accessibilityValue: String { + if let activity { + return "\(self.bridge.title), \(activity.title)" + } + return "\(self.bridge.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")" } private func updatePulse(for bridge: BridgeState, scenePhase: ScenePhase) {