mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-23 10:58:09 +00:00
Use the watchOS application API for text input, remove simulator-only Debug architecture restrictions, and document the standard Watch bundle location. Refs #92477. Co-authored-by: Sash Zats <sash@zats.io>
1340 lines
44 KiB
Swift
1340 lines
44 KiB
Swift
import SwiftUI
|
|
import WatchKit
|
|
|
|
struct WatchInboxView: View {
|
|
var store: WatchInboxStore
|
|
var onAction: ((WatchPromptAction) -> Void)?
|
|
var onExecApprovalDecision: ((String, WatchExecApprovalDecision) -> Void)?
|
|
var onRefreshExecApprovalReview: (() -> Void)?
|
|
var onRefreshAppSnapshot: (() -> Void)?
|
|
var onAppCommand: ((WatchAppCommand) -> Void)?
|
|
var onSendChatMessage: ((String) -> Void)?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
WatchControlSurfaceView(
|
|
store: self.store,
|
|
onAction: self.onAction,
|
|
onExecApprovalDecision: self.onExecApprovalDecision,
|
|
onRefreshExecApprovalReview: self.onRefreshExecApprovalReview,
|
|
onRefreshAppSnapshot: self.onRefreshAppSnapshot,
|
|
onAppCommand: self.onAppCommand,
|
|
onSendChatMessage: self.onSendChatMessage)
|
|
.toolbar(.hidden, for: .navigationBar)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WatchControlSurfaceView: View {
|
|
var store: WatchInboxStore
|
|
var onAction: ((WatchPromptAction) -> Void)?
|
|
var onExecApprovalDecision: ((String, WatchExecApprovalDecision) -> Void)?
|
|
var onRefreshExecApprovalReview: (() -> Void)?
|
|
var onRefreshAppSnapshot: (() -> Void)?
|
|
var onAppCommand: ((WatchAppCommand) -> Void)?
|
|
var onSendChatMessage: ((String) -> Void)?
|
|
@State private var selectedFace = 0
|
|
|
|
var body: some View {
|
|
TabView(selection: self.$selectedFace) {
|
|
self.nowFace
|
|
.tag(0)
|
|
self.stackFace
|
|
.tag(1)
|
|
self.approvalsFace
|
|
.tag(2)
|
|
}
|
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
|
.background(WatchClawStyle.background.ignoresSafeArea())
|
|
.navigationTitle("")
|
|
}
|
|
|
|
private var faceCount: Int {
|
|
3
|
|
}
|
|
|
|
private var pageRail: some View {
|
|
WatchPageRail(selectedIndex: self.selectedFace, pageCount: self.faceCount)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.bottom, -5)
|
|
.allowsHitTesting(false)
|
|
}
|
|
|
|
private var avatarImageSource: String? {
|
|
WatchAvatarSource.normalized(self.store.appSnapshot?.agentAvatarURL)
|
|
}
|
|
|
|
private var avatarText: String? {
|
|
WatchAvatarSource.normalized(self.store.appSnapshot?.agentAvatarText)
|
|
}
|
|
|
|
private var nowFace: some View {
|
|
WatchFaceScroll {
|
|
self.pageRail
|
|
WatchFaceHeader(
|
|
section: "Now",
|
|
title: self.greetingText,
|
|
subtitle: self.connectionLine,
|
|
isOnline: self.store.appSnapshot?.gatewayConnected == true,
|
|
avatarImageSource: self.avatarImageSource,
|
|
avatarText: self.avatarText)
|
|
|
|
NavigationLink {
|
|
self.primaryDestination
|
|
} label: {
|
|
WatchHeroCard(
|
|
label: self.primaryLabel,
|
|
title: self.primaryTitle,
|
|
subtitle: self.primarySubtitle,
|
|
accessory: self.store.talkSummaryText)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
NavigationLink {
|
|
self.chatTimelineDestination
|
|
} label: {
|
|
WatchPrimaryLabel(title: "Talk to Claw")
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if self.chatCount > 0 || self.approvalCount > 0 {
|
|
WatchCompactStatusStrip(
|
|
inboxCount: self.chatCountText,
|
|
approvalCount: self.approvalCountText,
|
|
status: self.statusLine)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var stackFace: some View {
|
|
WatchFaceScroll {
|
|
self.pageRail
|
|
WatchFaceHeader(
|
|
section: "Inbox",
|
|
title: "What needs you",
|
|
subtitle: self.inboxSubtitle,
|
|
isOnline: self.chatCount > 0 || self.approvalCount > 0,
|
|
avatarImageSource: self.avatarImageSource,
|
|
avatarText: self.avatarText)
|
|
|
|
if self.inboxHasItems {
|
|
if self.approvalCount > 0 {
|
|
self.inboxApprovalsLink
|
|
self.inboxChatLink
|
|
} else {
|
|
self.inboxChatLink
|
|
self.inboxApprovalsLink
|
|
}
|
|
self.inboxPromptBlock
|
|
} else {
|
|
WatchHeroCard(
|
|
label: "Clear",
|
|
title: "Caught up",
|
|
subtitle: self.store.hasAppSnapshot ? "No chats or approvals need you" : "Waiting for iPhone sync",
|
|
accessory: "Ready")
|
|
}
|
|
|
|
Button {
|
|
self.onAppCommand?(.openChat)
|
|
} label: {
|
|
WatchSecondaryLabel(title: "Continue on iPhone")
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private var inboxChatLink: some View {
|
|
if self.chatCount > 0 {
|
|
NavigationLink {
|
|
self.chatTimelineDestination
|
|
} label: {
|
|
WatchStackCard(
|
|
label: "Chat",
|
|
title: self.chatPreviewTitle,
|
|
subtitle: self.chatPreviewSubtitle,
|
|
badge: "\(self.chatCount)",
|
|
isProminent: self.approvalCount == 0)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private var inboxApprovalsLink: some View {
|
|
if self.approvalCount > 0 {
|
|
NavigationLink {
|
|
WatchExecApprovalListView(store: self.store, onDecision: self.onExecApprovalDecision)
|
|
} label: {
|
|
WatchStackCard(
|
|
label: "Approvals",
|
|
title: self.approvalHeadline,
|
|
subtitle: self.approvalSubtitle,
|
|
badge: "\(self.approvalCount)",
|
|
isProminent: true)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private var inboxPromptBlock: some View {
|
|
if self.store.hasMessagePrompt {
|
|
WatchHeroCard(
|
|
label: self.store.kind ?? "Latest",
|
|
title: self.store.title,
|
|
subtitle: self.store.body,
|
|
accessory: self.updatedText)
|
|
|
|
if let details = self.promptDetails {
|
|
WatchDetailText(text: details)
|
|
}
|
|
|
|
ForEach(self.store.actions) { action in
|
|
WatchActionCard(
|
|
title: action.label,
|
|
subtitle: self.actionSubtitle(action))
|
|
{
|
|
self.onAction?(action)
|
|
}
|
|
.disabled(self.store.isReplySending)
|
|
}
|
|
|
|
if let replyStatusText = self.store.replyStatusText, !replyStatusText.isEmpty {
|
|
WatchTinyStatus(text: replyStatusText)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var promptDetails: String? {
|
|
let details = self.store.details?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return details.isEmpty ? nil : details
|
|
}
|
|
|
|
private var inboxHasItems: Bool {
|
|
self.chatCount > 0 || self.approvalCount > 0 || self.store.hasMessagePrompt
|
|
}
|
|
|
|
private var inboxSubtitle: String {
|
|
if self.approvalCount > 0 {
|
|
return "Approval waiting"
|
|
}
|
|
if self.chatCount > 0 {
|
|
return self.chatStatusText
|
|
}
|
|
if self.store.hasMessagePrompt {
|
|
return self.store.kind ?? "Latest update"
|
|
}
|
|
return self.store.hasAppSnapshot ? "Nothing waiting" : "Waiting for iPhone"
|
|
}
|
|
|
|
private var approvalsFace: some View {
|
|
WatchFaceScroll {
|
|
self.pageRail
|
|
WatchFaceHeader(
|
|
section: "Approvals",
|
|
title: self.approvalHeadline,
|
|
subtitle: self.approvalHeaderSubtitle,
|
|
isOnline: self.approvalCount > 0,
|
|
avatarImageSource: self.avatarImageSource,
|
|
avatarText: self.avatarText)
|
|
|
|
if let record = self.store.activeExecApproval {
|
|
WatchHeroCard(
|
|
label: "Approval needed",
|
|
title: record.approval.commandPreview ?? record.approval.commandText,
|
|
subtitle: self.approvalDecisionSubtitle(record),
|
|
accessory: self.approvalAccessory(record))
|
|
|
|
if record.isResolving {
|
|
WatchTinyStatus(text: record.statusText ?? "Sending decision...")
|
|
} else {
|
|
HStack(spacing: 8) {
|
|
if record.approval.allowedDecisions.contains(.allowOnce) {
|
|
WatchDecisionButton(title: "Approve", color: .green) {
|
|
self.onExecApprovalDecision?(record.id, .allowOnce)
|
|
}
|
|
}
|
|
|
|
if record.approval.allowedDecisions.contains(.deny) {
|
|
WatchDecisionButton(title: "Deny", color: WatchClawStyle.accent) {
|
|
self.onExecApprovalDecision?(record.id, .deny)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let statusText = record.statusText, !statusText.isEmpty, !record.isResolving {
|
|
WatchTinyStatus(text: statusText)
|
|
}
|
|
} else {
|
|
WatchHeroCard(
|
|
label: "Clear",
|
|
title: "No approvals waiting",
|
|
subtitle: self.store.lastExecApprovalOutcomeText ?? "You are caught up",
|
|
accessory: "Ready")
|
|
|
|
if self.store.shouldShowExecApprovalReviewStatus {
|
|
WatchSecondaryButton(title: "Review again") {
|
|
self.onRefreshExecApprovalReview?()
|
|
}
|
|
}
|
|
}
|
|
|
|
if self.approvalCount > 1 {
|
|
NavigationLink {
|
|
WatchExecApprovalListView(store: self.store, onDecision: self.onExecApprovalDecision)
|
|
} label: {
|
|
WatchSecondaryLabel(title: "Open all approvals")
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var chatItems: [WatchChatItem] {
|
|
self.store.appSnapshot?.chatItems ?? []
|
|
}
|
|
|
|
private var chatTimelineDestination: some View {
|
|
WatchChatTimelineView(
|
|
items: self.chatItems,
|
|
statusText: self.chatStatusText,
|
|
sendStatusText: self.chatSendStatusText,
|
|
avatarImageSource: self.avatarImageSource,
|
|
avatarText: self.avatarText,
|
|
onRefresh: self.onRefreshAppSnapshot,
|
|
onSendMessage: self.onSendChatMessage)
|
|
}
|
|
|
|
@ViewBuilder private var primaryDestination: some View {
|
|
if let record = self.store.activeExecApproval {
|
|
WatchExecApprovalDetailView(
|
|
store: self.store,
|
|
record: record,
|
|
onDecision: self.onExecApprovalDecision)
|
|
} else {
|
|
self.chatTimelineDestination
|
|
}
|
|
}
|
|
|
|
private var chatCount: Int {
|
|
self.chatItems.count
|
|
}
|
|
|
|
private var approvalCount: Int {
|
|
max(self.store.sortedExecApprovals.count, self.store.appSnapshot?.pendingApprovalCount ?? 0)
|
|
}
|
|
|
|
private var chatCountText: String {
|
|
self.chatCount == 0 ? "0" : "\(self.chatCount)"
|
|
}
|
|
|
|
private var approvalCountText: String {
|
|
self.approvalCount == 0 ? "0" : "\(self.approvalCount)"
|
|
}
|
|
|
|
private var connectionLine: String {
|
|
if let snapshot = self.store.appSnapshot {
|
|
return snapshot.gatewayConnected ? "AI agent online" : "Reconnect on iPhone"
|
|
}
|
|
return "Pair iPhone"
|
|
}
|
|
|
|
private var primaryLabel: String {
|
|
if self.store.activeExecApproval != nil { return "Next up" }
|
|
return self.store.appSnapshot?.gatewayConnected == true ? "Running" : "Pairing"
|
|
}
|
|
|
|
private var primaryTitle: String {
|
|
if let record = self.store.activeExecApproval {
|
|
return record.approval.commandPreview ?? record.approval.commandText
|
|
}
|
|
if self.chatCount > 0 {
|
|
return self.chatItems.last?.text ?? self.store.gatewaySummaryText
|
|
}
|
|
return self.store.gatewaySummaryText
|
|
}
|
|
|
|
private var primarySubtitle: String {
|
|
if self.store.activeExecApproval != nil {
|
|
return "Approval waiting on your wrist"
|
|
}
|
|
if self.chatCount > 0 {
|
|
return self.chatStatusText
|
|
}
|
|
return self.store.hasAppSnapshot ? "Ready for quick actions" : "Waiting for iPhone sync"
|
|
}
|
|
|
|
private var approvalHeadline: String {
|
|
self.approvalCount == 1 ? "1 approval waiting" : "\(self.approvalCount) approvals"
|
|
}
|
|
|
|
private var approvalSubtitle: String {
|
|
guard let record = self.store.activeExecApproval else { return "No approvals waiting" }
|
|
return record.approval.commandPreview ?? record.approval.commandText
|
|
}
|
|
|
|
private var approvalHeaderSubtitle: String {
|
|
self.approvalCount > 0 ? "Decide from watch" : "No approvals"
|
|
}
|
|
|
|
private func approvalDecisionSubtitle(_ record: WatchExecApprovalRecord) -> String {
|
|
var parts: [String] = []
|
|
if let expiresText = self.expiryText(record.approval.expiresAtMs) {
|
|
parts.append("Expires in \(expiresText)")
|
|
}
|
|
if let host = record.approval.host, !host.isEmpty {
|
|
parts.append(host)
|
|
}
|
|
if parts.isEmpty {
|
|
parts.append("Review before it runs")
|
|
}
|
|
return parts.joined(separator: " · ")
|
|
}
|
|
|
|
private func approvalAccessory(_ record: WatchExecApprovalRecord) -> String {
|
|
if record.isResolving {
|
|
return "Sending"
|
|
}
|
|
if let risk = self.approvalRiskText(record.approval.risk) {
|
|
return risk
|
|
}
|
|
return "Review"
|
|
}
|
|
|
|
private func approvalRiskText(_ risk: WatchRiskLevel?) -> String? {
|
|
switch risk {
|
|
case .high:
|
|
"High risk"
|
|
case .medium:
|
|
"Medium risk"
|
|
case .low:
|
|
"Low risk"
|
|
case nil:
|
|
nil
|
|
}
|
|
}
|
|
|
|
private var chatPreviewTitle: String {
|
|
guard let item = self.chatItems.last else { return "No chat synced" }
|
|
return self.roleTitle(item.role)
|
|
}
|
|
|
|
private var chatPreviewSubtitle: String {
|
|
self.chatItems.last?.text ?? self.chatStatusText
|
|
}
|
|
|
|
private var chatStatusText: String {
|
|
if let status = self.store.appSnapshot?.chatStatusText, !status.isEmpty {
|
|
return status
|
|
}
|
|
if self.chatCount > 0 {
|
|
return self.chatCount == 1 ? "1 recent message" : "\(self.chatCount) recent messages"
|
|
}
|
|
return self.store.hasAppSnapshot ? "No messages synced" : "Waiting for iPhone"
|
|
}
|
|
|
|
private var chatSendStatusText: String? {
|
|
guard let status = self.store.appCommandStatusText, status.hasPrefix("Chat:") else {
|
|
return nil
|
|
}
|
|
return status
|
|
}
|
|
|
|
private var greetingText: String {
|
|
if let greetingTextOverride = self.store.greetingTextOverride {
|
|
return greetingTextOverride
|
|
}
|
|
let hour = Calendar.current.component(.hour, from: Date())
|
|
if hour < 12 { return "Good morning" }
|
|
if hour < 18 { return "Good afternoon" }
|
|
return "Good evening"
|
|
}
|
|
|
|
private var statusLine: String {
|
|
if let status = self.store.appSnapshotStatusText, !status.isEmpty {
|
|
return status
|
|
}
|
|
if let commandStatus = self.store.appCommandStatusText, !commandStatus.isEmpty {
|
|
return commandStatus
|
|
}
|
|
if let replyStatus = self.store.replyStatusText, !replyStatus.isEmpty {
|
|
return replyStatus
|
|
}
|
|
return self.store.hasAppSnapshot ? "Synced" : "Waiting for iPhone"
|
|
}
|
|
|
|
private var updatedText: String {
|
|
guard let updatedAt = self.store.updatedAt else { return "Just now" }
|
|
return updatedAt.formatted(date: .omitted, time: .shortened)
|
|
}
|
|
|
|
private func roleTitle(_ role: String) -> String {
|
|
switch role.lowercased() {
|
|
case "user":
|
|
"You"
|
|
case "system":
|
|
"System"
|
|
default:
|
|
"OpenClaw"
|
|
}
|
|
}
|
|
|
|
private func actionSubtitle(_ action: WatchPromptAction) -> String {
|
|
switch action.style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
|
case "destructive":
|
|
"Requires confirmation"
|
|
case "cancel":
|
|
"Dismiss this update"
|
|
default:
|
|
"Send from watch"
|
|
}
|
|
}
|
|
|
|
private func expiryText(_ expiresAtMs: Int?) -> String? {
|
|
guard let expiresAtMs else { return nil }
|
|
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
|
|
if deltaSeconds < 60 {
|
|
return "<1m"
|
|
}
|
|
return "\(deltaSeconds / 60)m"
|
|
}
|
|
}
|
|
|
|
private enum WatchClawStyle {
|
|
static let accent = Color(red: 1.0, green: 0.2, blue: 0.22)
|
|
static let background = Color(red: 0.015, green: 0.015, blue: 0.02)
|
|
static let surface = Color.white.opacity(0.075)
|
|
static let raised = Color.white.opacity(0.115)
|
|
static let border = Color.white.opacity(0.10)
|
|
static let hotGradient = LinearGradient(
|
|
colors: [Self.accent, Color(red: 0.78, green: 0.05, blue: 0.08)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing)
|
|
}
|
|
|
|
private struct WatchFaceScroll<Content: View>: View {
|
|
@ViewBuilder var content: Content
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
self.content
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.top, 0)
|
|
.padding(.bottom, 40)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
}
|
|
.background(WatchClawStyle.background.ignoresSafeArea())
|
|
.scrollIndicators(.hidden)
|
|
}
|
|
}
|
|
|
|
private enum WatchAvatarSource {
|
|
static func normalized(_ value: String?) -> String? {
|
|
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
static func dataImage(from source: String?) -> UIImage? {
|
|
guard let source = self.normalized(source),
|
|
source.lowercased().hasPrefix("data:image/"),
|
|
let commaIndex = source.firstIndex(of: ",")
|
|
else {
|
|
return nil
|
|
}
|
|
let header = source[..<commaIndex].lowercased()
|
|
guard header.contains(";base64") else { return nil }
|
|
let base64 = String(source[source.index(after: commaIndex)...])
|
|
guard let data = Data(base64Encoded: base64) else { return nil }
|
|
return UIImage(data: data)
|
|
}
|
|
|
|
static func remoteURL(from source: String?) -> URL? {
|
|
guard let source = self.normalized(source),
|
|
let url = URL(string: source),
|
|
let scheme = url.scheme?.lowercased(),
|
|
scheme == "https" || scheme == "http"
|
|
else {
|
|
return nil
|
|
}
|
|
return url
|
|
}
|
|
}
|
|
|
|
private struct WatchClawAvatar: View {
|
|
var size: CGFloat
|
|
var imageSource: String?
|
|
var text: String?
|
|
@State private var dataImage: UIImage?
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.black.opacity(0.30))
|
|
self.avatarContent
|
|
.padding(self.contentPadding)
|
|
}
|
|
.frame(width: self.size, height: self.size)
|
|
.clipShape(Circle())
|
|
.overlay {
|
|
Circle()
|
|
.strokeBorder(WatchClawStyle.accent.opacity(0.32), lineWidth: 1)
|
|
}
|
|
.shadow(color: WatchClawStyle.accent.opacity(0.30), radius: 5, y: 2)
|
|
.task(id: WatchAvatarSource.normalized(self.imageSource)) {
|
|
self.dataImage = WatchAvatarSource.dataImage(from: self.imageSource)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private var avatarContent: some View {
|
|
if let image = self.dataImage {
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.scaledToFill()
|
|
} else if let url = WatchAvatarSource.remoteURL(from: self.imageSource) {
|
|
AsyncImage(url: url) { phase in
|
|
switch phase {
|
|
case let .success(image):
|
|
image
|
|
.resizable()
|
|
.scaledToFill()
|
|
default:
|
|
self.fallbackContent
|
|
}
|
|
}
|
|
} else {
|
|
self.fallbackContent
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private var fallbackContent: some View {
|
|
if let text = WatchAvatarSource.normalized(self.text) {
|
|
Text(String(text.prefix(3)))
|
|
.font(.system(size: self.size * 0.42, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
.minimumScaleFactor(0.6)
|
|
.lineLimit(1)
|
|
} else {
|
|
Image("OpenClawIcon")
|
|
.resizable()
|
|
.scaledToFit()
|
|
}
|
|
}
|
|
|
|
private var contentPadding: CGFloat {
|
|
WatchAvatarSource.normalized(self.imageSource) == nil ? self.size * 0.04 : 0
|
|
}
|
|
}
|
|
|
|
private struct WatchFaceHeader: View {
|
|
let section: String
|
|
let title: String
|
|
let subtitle: String
|
|
let isOnline: Bool
|
|
var avatarImageSource: String?
|
|
var avatarText: String?
|
|
|
|
var body: some View {
|
|
HStack(alignment: .center, spacing: 7) {
|
|
WatchClawAvatar(
|
|
size: 23,
|
|
imageSource: self.avatarImageSource,
|
|
text: self.avatarText)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(self.section)
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundStyle(WatchClawStyle.accent)
|
|
.lineLimit(1)
|
|
Text(self.title)
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
Text(self.subtitle)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WatchHeroCard: View {
|
|
let label: String
|
|
let title: String
|
|
let subtitle: String
|
|
let accessory: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(alignment: .center) {
|
|
Text(self.label)
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundStyle(WatchClawStyle.accent)
|
|
.lineLimit(1)
|
|
Spacer(minLength: 4)
|
|
Text(self.accessory)
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
Text(self.title)
|
|
.font(.system(size: 19, weight: .semibold))
|
|
.lineLimit(3)
|
|
.minimumScaleFactor(0.75)
|
|
Text(self.subtitle)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
.padding(8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: 17, style: .continuous)
|
|
.fill(WatchClawStyle.raised)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 17, style: .continuous)
|
|
.strokeBorder(WatchClawStyle.border, lineWidth: 1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WatchDetailText: View {
|
|
let text: String
|
|
|
|
var body: some View {
|
|
Text(self.text)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(5)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 7)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.fill(Color.white.opacity(0.055))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WatchCompactStatusStrip: View {
|
|
let inboxCount: String
|
|
let approvalCount: String
|
|
let status: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: 5) {
|
|
WatchCompactMetric(label: "Inbox", value: self.inboxCount)
|
|
WatchCompactMetric(label: "Approvals", value: self.approvalCount)
|
|
Text(self.status)
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.75)
|
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 6)
|
|
.background {
|
|
Capsule(style: .continuous)
|
|
.fill(Color.white.opacity(0.06))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WatchCompactMetric: View {
|
|
let label: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: 3) {
|
|
Text(self.label)
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
Text(self.value)
|
|
.font(.system(size: 10, weight: .bold))
|
|
}
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
private struct WatchPrimaryLabel: View {
|
|
let title: String
|
|
|
|
var body: some View {
|
|
HStack(spacing: 7) {
|
|
WatchVoiceGlyph()
|
|
Text(self.title)
|
|
.font(.caption.weight(.bold))
|
|
.lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 7)
|
|
.background {
|
|
Capsule(style: .continuous)
|
|
.fill(WatchClawStyle.hotGradient)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WatchVoiceGlyph: View {
|
|
var body: some View {
|
|
HStack(alignment: .center, spacing: 2) {
|
|
ForEach([7.0, 13.0, 18.0, 12.0, 8.0], id: \.self) { height in
|
|
Capsule(style: .continuous)
|
|
.fill(.white.opacity(0.82))
|
|
.frame(width: 2, height: height)
|
|
}
|
|
}
|
|
.frame(width: 20, height: 20)
|
|
}
|
|
}
|
|
|
|
private struct WatchPageRail: View {
|
|
let selectedIndex: Int
|
|
let pageCount: Int
|
|
|
|
var body: some View {
|
|
HStack(spacing: 5) {
|
|
ForEach(0..<max(self.pageCount, 1), id: \.self) { index in
|
|
Capsule(style: .continuous)
|
|
.fill(index == self.selectedIndex ? WatchClawStyle.accent : Color.white.opacity(0.20))
|
|
.frame(width: index == self.selectedIndex ? 13 : 4, height: 4)
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 1)
|
|
}
|
|
}
|
|
|
|
private struct WatchSecondaryLabel: View {
|
|
let title: String
|
|
|
|
var body: some View {
|
|
Text(self.title)
|
|
.font(.caption.weight(.semibold))
|
|
.lineLimit(1)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 8)
|
|
.background {
|
|
Capsule(style: .continuous)
|
|
.fill(Color.white.opacity(0.08))
|
|
.overlay {
|
|
Capsule(style: .continuous)
|
|
.strokeBorder(WatchClawStyle.border, lineWidth: 1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WatchSecondaryButton: View {
|
|
let title: String
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: self.action) {
|
|
WatchSecondaryLabel(title: self.title)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private struct WatchStackCard: View {
|
|
let label: String
|
|
let title: String
|
|
let subtitle: String
|
|
let badge: String?
|
|
var isProminent = false
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(self.label)
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundStyle(WatchClawStyle.accent)
|
|
.lineLimit(1)
|
|
Text(self.title)
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.lineLimit(1)
|
|
Text(self.subtitle)
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
Spacer(minLength: 2)
|
|
HStack(spacing: 5) {
|
|
if let badge {
|
|
Text(badge)
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.frame(minWidth: 18, minHeight: 18)
|
|
.background {
|
|
Circle()
|
|
.fill(WatchClawStyle.accent)
|
|
}
|
|
}
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 7)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: 17, style: .continuous)
|
|
.fill(self.isProminent ? WatchClawStyle.raised : WatchClawStyle.surface)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 17, style: .continuous)
|
|
.strokeBorder(WatchClawStyle.border, lineWidth: 1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WatchActionCard: View {
|
|
let title: String
|
|
let subtitle: String
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: self.action) {
|
|
WatchStackCard(
|
|
label: "OpenClaw",
|
|
title: self.title,
|
|
subtitle: self.subtitle,
|
|
badge: nil)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private struct WatchDecisionButton: View {
|
|
let title: String
|
|
let color: Color
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: self.action) {
|
|
Text(self.title)
|
|
.font(.caption.weight(.bold))
|
|
.lineLimit(1)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 9)
|
|
.background {
|
|
Capsule(style: .continuous)
|
|
.fill(self.color)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private struct WatchTinyStatus: View {
|
|
let text: String
|
|
|
|
var body: some View {
|
|
Text(self.text)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
private struct WatchChatBubble: View {
|
|
let item: WatchChatItem
|
|
var avatarImageSource: String?
|
|
var avatarText: String?
|
|
|
|
var body: some View {
|
|
HStack(alignment: .bottom, spacing: 6) {
|
|
if !self.isUser {
|
|
WatchClawAvatar(
|
|
size: 18,
|
|
imageSource: self.avatarImageSource,
|
|
text: self.avatarText)
|
|
} else {
|
|
Spacer(minLength: 20)
|
|
}
|
|
|
|
VStack(alignment: self.isUser ? .trailing : .leading, spacing: 3) {
|
|
Text(self.roleTitle)
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundStyle(self.isUser ? .secondary : WatchClawStyle.accent)
|
|
Text(self.item.text)
|
|
.font(.system(size: 13))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(.horizontal, 9)
|
|
.padding(.vertical, 7)
|
|
.frame(maxWidth: 132, alignment: self.isUser ? .trailing : .leading)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: 15, style: .continuous)
|
|
.fill(self.isUser ? WatchClawStyle.accent.opacity(0.88) : WatchClawStyle.surface)
|
|
}
|
|
|
|
if self.isUser {
|
|
WatchMiniUserDot()
|
|
} else {
|
|
Spacer(minLength: 20)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private var isUser: Bool {
|
|
self.item.role.lowercased() == "user"
|
|
}
|
|
|
|
private var roleTitle: String {
|
|
switch self.item.role.lowercased() {
|
|
case "user":
|
|
"You"
|
|
case "system":
|
|
"System"
|
|
default:
|
|
"OpenClaw"
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WatchChatTimelineView: View {
|
|
let items: [WatchChatItem]
|
|
let statusText: String
|
|
let sendStatusText: String?
|
|
var avatarImageSource: String?
|
|
var avatarText: String?
|
|
var onRefresh: (() -> Void)?
|
|
var onSendMessage: ((String) -> Void)?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 7) {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if self.items.isEmpty {
|
|
WatchChatEmptyState(statusText: self.statusText)
|
|
} else {
|
|
ForEach(self.items) { item in
|
|
WatchChatBubble(
|
|
item: item,
|
|
avatarImageSource: self.avatarImageSource,
|
|
avatarText: self.avatarText)
|
|
}
|
|
}
|
|
|
|
if let sendStatusText, !sendStatusText.isEmpty {
|
|
WatchTinyStatus(text: sendStatusText)
|
|
}
|
|
|
|
WatchSecondaryButton(title: "Refresh") {
|
|
self.onRefresh?()
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 4)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
|
|
WatchChatComposer(
|
|
onSendMessage: { text in
|
|
self.sendMessage(text)
|
|
})
|
|
.padding(.horizontal, 7)
|
|
.padding(.bottom, 5)
|
|
}
|
|
.background(WatchClawStyle.background.ignoresSafeArea())
|
|
.navigationTitle("Chat")
|
|
}
|
|
|
|
private func sendMessage(_ text: String) {
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
self.onSendMessage?(trimmed)
|
|
}
|
|
}
|
|
|
|
private struct WatchChatEmptyState: View {
|
|
let statusText: String
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("No chat synced")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.lineLimit(2)
|
|
Text(self.statusText)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(3)
|
|
Text("Tap the message pill below to start from your watch.")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(WatchClawStyle.accent)
|
|
.lineLimit(2)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 9)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: 17, style: .continuous)
|
|
.fill(WatchClawStyle.surface)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 17, style: .continuous)
|
|
.strokeBorder(WatchClawStyle.border, lineWidth: 1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WatchMiniUserDot: View {
|
|
var body: some View {
|
|
Text("You")
|
|
.font(.system(size: 8, weight: .bold))
|
|
.foregroundStyle(.white.opacity(0.86))
|
|
.frame(width: 22, height: 18)
|
|
.background {
|
|
Capsule(style: .continuous)
|
|
.fill(Color.white.opacity(0.10))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct WatchChatComposer: View {
|
|
let onSendMessage: (String) -> Void
|
|
|
|
var body: some View {
|
|
Button {
|
|
WatchNativeTextInput.present(
|
|
suggestions: [],
|
|
onSubmit: self.onSendMessage)
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Text("Message OpenClaw")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
Spacer(minLength: 0)
|
|
WatchVoiceGlyph()
|
|
.frame(width: 18, height: 18)
|
|
.padding(5)
|
|
.background {
|
|
Circle()
|
|
.fill(WatchClawStyle.hotGradient)
|
|
}
|
|
}
|
|
.padding(.leading, 12)
|
|
.padding(.trailing, 5)
|
|
.padding(.vertical, 5)
|
|
.background {
|
|
Capsule(style: .continuous)
|
|
.fill(Color.white.opacity(0.09))
|
|
.overlay {
|
|
Capsule(style: .continuous)
|
|
.strokeBorder(Color.white.opacity(0.16), lineWidth: 1)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private enum WatchNativeTextInput {
|
|
@MainActor
|
|
static func present(
|
|
suggestions: [String],
|
|
onSubmit: @escaping (String) -> Void)
|
|
{
|
|
WKApplication.shared().visibleInterfaceController?.presentTextInputController(
|
|
withSuggestions: suggestions,
|
|
allowedInputMode: .allowEmoji)
|
|
{ results in
|
|
guard let text = results?.compactMap(stringValue).first?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!text.isEmpty
|
|
else {
|
|
return
|
|
}
|
|
onSubmit(text)
|
|
}
|
|
}
|
|
|
|
private static func stringValue(_ result: Any) -> String? {
|
|
if let string = result as? String {
|
|
return string
|
|
}
|
|
if let attributed = result as? NSAttributedString {
|
|
return attributed.string
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private struct WatchExecApprovalListView: View {
|
|
var store: WatchInboxStore
|
|
var onDecision: ((String, WatchExecApprovalDecision) -> Void)?
|
|
|
|
var body: some View {
|
|
WatchDetailScroll(title: "Approvals") {
|
|
if self.store.sortedExecApprovals.isEmpty {
|
|
WatchHeroCard(
|
|
label: "Clear",
|
|
title: "No approvals waiting",
|
|
subtitle: self.store.lastExecApprovalOutcomeText ?? "You are caught up",
|
|
accessory: "Ready")
|
|
} else {
|
|
ForEach(self.store.sortedExecApprovals) { record in
|
|
NavigationLink {
|
|
WatchExecApprovalDetailView(
|
|
store: self.store,
|
|
record: record,
|
|
onDecision: self.onDecision)
|
|
} label: {
|
|
WatchStackCard(
|
|
label: "Approval",
|
|
title: record.approval.commandPreview ?? record.approval.commandText,
|
|
subtitle: self.metadataLine(for: record),
|
|
badge: nil)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
if let outcome = self.store.lastExecApprovalOutcomeText, !outcome.isEmpty {
|
|
WatchTinyStatus(text: outcome)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func metadataLine(for record: WatchExecApprovalRecord) -> String {
|
|
var parts: [String] = []
|
|
if let host = record.approval.host, !host.isEmpty {
|
|
parts.append(host)
|
|
}
|
|
if let nodeId = record.approval.nodeId, !nodeId.isEmpty {
|
|
parts.append(nodeId)
|
|
}
|
|
if let expiresText = Self.expiresText(record.approval.expiresAtMs) {
|
|
parts.append(expiresText)
|
|
}
|
|
if let statusText = record.statusText, !statusText.isEmpty {
|
|
parts.append(statusText)
|
|
}
|
|
return parts.isEmpty ? "Pending review" : parts.joined(separator: " · ")
|
|
}
|
|
|
|
private static func expiresText(_ expiresAtMs: Int?) -> String? {
|
|
guard let expiresAtMs else { return nil }
|
|
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
|
|
if deltaSeconds < 60 {
|
|
return "Expires in <1m"
|
|
}
|
|
return "Expires in \(deltaSeconds / 60)m"
|
|
}
|
|
}
|
|
|
|
private struct WatchExecApprovalDetailView: View {
|
|
var store: WatchInboxStore
|
|
let record: WatchExecApprovalRecord
|
|
var onDecision: ((String, WatchExecApprovalDecision) -> Void)?
|
|
|
|
var body: some View {
|
|
WatchDetailScroll(title: "Approval") {
|
|
WatchHeroCard(
|
|
label: self.riskText(self.currentRecord?.approval.risk ?? self.record.approval.risk) ?? "Review",
|
|
title: self.currentRecord?.approval.commandText ?? self.record.approval.commandText,
|
|
subtitle: self.metadataSummary,
|
|
accessory: Self
|
|
.expiresText(self.currentRecord?.approval.expiresAtMs ?? self.record.approval.expiresAtMs) ?? "Now")
|
|
|
|
if let statusText = self.currentRecord?.statusText, !statusText.isEmpty {
|
|
WatchTinyStatus(text: statusText)
|
|
}
|
|
|
|
if let currentRecord {
|
|
if currentRecord.isResolving {
|
|
WatchTinyStatus(text: "Sending decision...")
|
|
} else {
|
|
HStack(spacing: 8) {
|
|
if currentRecord.approval.allowedDecisions.contains(.allowOnce) {
|
|
WatchDecisionButton(title: "Approve", color: .green) {
|
|
self.onDecision?(currentRecord.id, .allowOnce)
|
|
}
|
|
}
|
|
|
|
if currentRecord.approval.allowedDecisions.contains(.deny) {
|
|
WatchDecisionButton(title: "Deny", color: WatchClawStyle.accent) {
|
|
self.onDecision?(currentRecord.id, .deny)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
self.store.selectExecApproval(id: self.record.id)
|
|
}
|
|
}
|
|
|
|
private var currentRecord: WatchExecApprovalRecord? {
|
|
self.store.execApprovals.first(where: { $0.id == self.record.id })
|
|
}
|
|
|
|
private var metadataSummary: String {
|
|
let approval = self.currentRecord?.approval ?? self.record.approval
|
|
var parts: [String] = []
|
|
if let host = approval.host, !host.isEmpty {
|
|
parts.append(host)
|
|
}
|
|
if let nodeId = approval.nodeId, !nodeId.isEmpty {
|
|
parts.append(nodeId)
|
|
}
|
|
if let agentId = approval.agentId, !agentId.isEmpty {
|
|
parts.append(agentId)
|
|
}
|
|
return parts.isEmpty ? "Tap to decide" : parts.joined(separator: " · ")
|
|
}
|
|
|
|
private func riskText(_ risk: WatchRiskLevel?) -> String? {
|
|
switch risk {
|
|
case .high:
|
|
"High risk"
|
|
case .medium:
|
|
"Medium risk"
|
|
case .low:
|
|
"Low risk"
|
|
case nil:
|
|
nil
|
|
}
|
|
}
|
|
|
|
private static func expiresText(_ expiresAtMs: Int?) -> String? {
|
|
guard let expiresAtMs else { return nil }
|
|
let deltaSeconds = max(0, (expiresAtMs - Int(Date().timeIntervalSince1970 * 1000)) / 1000)
|
|
if deltaSeconds < 60 {
|
|
return "<1 minute"
|
|
}
|
|
return "\(deltaSeconds / 60) minutes"
|
|
}
|
|
}
|
|
|
|
private struct WatchDetailScroll<Content: View>: View {
|
|
let title: String
|
|
@ViewBuilder var content: Content
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 9) {
|
|
self.content
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 9)
|
|
.padding(.bottom, 18)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
}
|
|
.background(WatchClawStyle.background.ignoresSafeArea())
|
|
.navigationTitle(self.title)
|
|
}
|
|
}
|