fix(watchos): localize inbox runtime copy

This commit is contained in:
Vincent Koc
2026-07-12 18:33:47 +02:00
committed by Vincent Koc
parent ee3104340d
commit 551581d0a4
3 changed files with 304 additions and 179 deletions
+25 -14
View File
@@ -59,7 +59,11 @@ final class WatchDirectNode {
let detail: String
var errorDescription: String? {
self.detail.isEmpty ? "Gateway HTTP error (\(self.status))" : self.detail
self.detail.isEmpty
? String(
format: String(localized: "Gateway HTTP error (%@)"),
self.status.formatted())
: self.detail
}
}
@@ -85,7 +89,8 @@ final class WatchDirectNode {
private(set) var isEnabled: Bool
private(set) var isConnected = false
private(set) var statusText = "Use iPhone Settings to enable direct connection."
private(set) var statusText = String(
localized: "Use iPhone Settings to enable direct connection.")
private(set) var endpointText: String?
var isConfigured: Bool {
@@ -112,7 +117,9 @@ final class WatchDirectNode {
}
self.endpointText = self.configuration.map(Self.endpointText)
if self.configuration != nil {
self.statusText = self.isEnabled ? "Ready to connect" : "Direct connection is off"
self.statusText = self.isEnabled
? String(localized: "Ready to connect")
: String(localized: "Direct connection is off")
}
}
@@ -121,7 +128,8 @@ final class WatchDirectNode {
let oldestAcceptedMs = nowMs - Self.maximumSetupAgeMs
let newestAcceptedMs = nowMs + Self.maximumSetupClockSkewMs
guard (oldestAcceptedMs...newestAcceptedMs).contains(sentAtMs) else {
self.statusText = "Ignored an expired direct connection setup. Send setup again from iPhone."
self.statusText = String(
localized: "Ignored an expired direct connection setup. Send setup again from iPhone.")
return
}
let newestInstalledSetupMs = configuration?.setupSentAtMs ?? 0
@@ -133,7 +141,8 @@ final class WatchDirectNode {
link.password == nil,
let secureEndpoint = link.connectionEndpoints.first(where: \.tls)
else {
self.statusText = "Direct mode requires a trusted HTTPS Gateway endpoint."
self.statusText = String(
localized: "Direct mode requires a trusted HTTPS Gateway endpoint.")
return
}
let secureEndpoints = link.connectionEndpoints.filter(\.tls)
@@ -151,7 +160,7 @@ final class WatchDirectNode {
gatewayID: Self.gatewayID(for: secureLink),
setupSentAtMs: sentAtMs)
guard Self.saveConfiguration(configuration) else {
self.statusText = "Could not save direct connection securely."
self.statusText = String(localized: "Could not save direct connection securely.")
return
}
if let previousConfiguration,
@@ -168,7 +177,7 @@ final class WatchDirectNode {
self.disconnectActiveSession()
self.configuration = configuration
self.endpointText = Self.endpointText(configuration)
self.statusText = "Setup received. Connecting…"
self.statusText = String(localized: "Setup received. Connecting…")
self.setEnabled(true)
}
@@ -184,8 +193,8 @@ final class WatchDirectNode {
self.connectTask = nil
self.isConnected = false
self.statusText = self.isConfigured
? "Direct connection is off"
: "Use iPhone Settings to enable direct connection."
? String(localized: "Direct connection is off")
: String(localized: "Use iPhone Settings to enable direct connection.")
}
}
@@ -213,7 +222,7 @@ final class WatchDirectNode {
self.connectTask = nil
self.isConnected = false
if self.isEnabled, self.isConfigured {
self.statusText = "Reconnects when OpenClaw is active"
self.statusText = String(localized: "Reconnects when OpenClaw is active")
}
}
@@ -266,8 +275,10 @@ final class WatchDirectNode {
}
guard self.isCurrentConnection(generation, configuration: configuration) else { return }
self.statusText = lastError.map {
"Direct connection failed: \($0.localizedDescription)"
} ?? "No usable Gateway endpoint"
String(
format: String(localized: "Direct connection failed: %@"),
$0.localizedDescription)
} ?? String(localized: "No usable Gateway endpoint")
do {
try await Task.sleep(for: .seconds(3))
} catch {
@@ -283,7 +294,7 @@ final class WatchDirectNode {
generation: Int) async throws
{
try self.requireCurrentConnection(generation, configuration: configuration)
self.statusText = "Connecting directly…"
self.statusText = String(localized: "Connecting directly…")
guard let identity = DeviceIdentityStore.loadOrCreatePersisted(profile: .primary) else {
throw HTTPError(status: 0, detail: "Could not save the watch device identity")
}
@@ -344,7 +355,7 @@ final class WatchDirectNode {
self.activeSession = session
defer { releaseActiveSession(session) }
self.isConnected = true
self.statusText = "Connected directly"
self.statusText = String(localized: "Connected directly")
while self.isCurrentConnection(generation, configuration: configuration) {
let pollData = try await request(
baseURL: baseURL,
+75 -44
View File
@@ -254,22 +254,26 @@ import WatchKit
}
var gatewaySummaryText: String {
guard let appSnapshot else { return "Waiting for iPhone" }
return appSnapshot.gatewayConnected ? "Connected" : appSnapshot.gatewayStatusText
guard let appSnapshot else { return String(localized: "Waiting for iPhone") }
return appSnapshot.gatewayConnected
? String(localized: "Connected")
: appSnapshot.gatewayStatusText
}
var talkSummaryText: String {
guard let appSnapshot else { return "Not synced" }
guard let appSnapshot else { return String(localized: "Not synced") }
if appSnapshot.talkListening {
return "Listening"
return String(localized: "Listening")
}
if appSnapshot.talkSpeaking {
return "Speaking"
return String(localized: "Speaking")
}
if appSnapshot.talkEnabled {
return appSnapshot.talkStatusText.isEmpty ? "Ready" : appSnapshot.talkStatusText
return appSnapshot.talkStatusText.isEmpty
? String(localized: "Ready")
: appSnapshot.talkStatusText
}
return "Off"
return String(localized: "Off")
}
func beginExecApprovalReviewLoading() {
@@ -278,7 +282,7 @@ import WatchKit
return
}
self.isExecApprovalReviewLoading = true
self.execApprovalReviewStatusText = "Loading approval from iPhone…"
self.execApprovalReviewStatusText = String(localized: "Loading approval from iPhone…")
self.execApprovalReviewStatusAt = Date()
}
@@ -398,7 +402,7 @@ import WatchKit
Task {
await self.postLocalNotification(
identifier: notificationIdentifier,
title: "Exec approval required",
title: String(localized: "Exec approval required"),
body: message.approval.commandPreview ?? message.approval.commandText,
risk: message.approval.risk?.rawValue,
stillCurrent: {
@@ -504,7 +508,7 @@ import WatchKit
_ = self.recordExecApprovalTerminal(
approvalId: record.approvalID,
gatewayStableID: record.approval.gatewayStableID,
outcomeText: "Approval resolved elsewhere",
outcomeText: String(localized: "Approval resolved elsewhere"),
authoritativeOutcome: false)
return false
}
@@ -606,17 +610,19 @@ import WatchKit
}
func markAppSnapshotRequestStarted() {
self.appSnapshotStatusText = "Refreshing from iPhone…"
self.appSnapshotStatusText = String(localized: "Refreshing from iPhone…")
self.persistState()
}
func markAppSnapshotRequestResult(_ result: WatchReplySendResult) {
if let errorMessage = result.errorMessage, !errorMessage.isEmpty {
self.appSnapshotStatusText = "Refresh failed: \(errorMessage)"
self.appSnapshotStatusText = String(
format: String(localized: "Refresh failed: %@"),
errorMessage)
} else if result.deliveredImmediately {
self.appSnapshotStatusText = "Refresh requested"
self.appSnapshotStatusText = String(localized: "Refresh requested")
} else if result.queuedForDelivery {
self.appSnapshotStatusText = "Refresh queued"
self.appSnapshotStatusText = String(localized: "Refresh queued")
} else {
self.appSnapshotStatusText = nil
}
@@ -639,25 +645,33 @@ import WatchKit
}
func markAppCommandSending(_ command: WatchAppCommand) {
self.appCommandStatusText = "Sending \(Self.commandLabel(command))"
self.appCommandStatusText = String(
format: String(localized: "Sending %@…"),
Self.commandLabel(command))
self.persistState()
}
func markAppCommandBlocked(_ command: WatchAppCommand, reason: String) {
self.appCommandStatusText = "\(Self.commandLabel(command)): \(reason)"
self.appCommandStatusText = String(
format: String(localized: "%@: %@"),
Self.commandLabel(command),
reason)
self.persistState()
}
func markAppCommandResult(_ result: WatchReplySendResult, command: WatchAppCommand) {
let label = Self.commandLabel(command)
if let errorMessage = result.errorMessage, !errorMessage.isEmpty {
self.appCommandStatusText = "\(label) failed: \(errorMessage)"
self.appCommandStatusText = String(
format: String(localized: "%@ failed: %@"),
label,
errorMessage)
} else if result.deliveredImmediately {
self.appCommandStatusText = "\(label): sent"
self.appCommandStatusText = String(format: String(localized: "%@: sent"), label)
} else if result.queuedForDelivery {
self.appCommandStatusText = "\(label): queued"
self.appCommandStatusText = String(format: String(localized: "%@: queued"), label)
} else {
self.appCommandStatusText = "\(label): sent"
self.appCommandStatusText = String(format: String(localized: "%@: sent"), label)
}
self.persistState()
}
@@ -675,11 +689,11 @@ extension WatchInboxStore {
} else {
switch message.decision {
case .allowOnce:
"Allowed once"
String(localized: "Allowed once")
case .deny:
"Denied"
String(localized: "Denied")
case nil:
"Approval resolved"
String(localized: "Approval resolved")
}
}
let terminalOutcomeText = self.recordExecApprovalTerminal(
@@ -697,15 +711,15 @@ extension WatchInboxStore {
guard self.routeGatewayPayload(.execApprovalExpired(message: message)) else { return }
let statusText = switch message.reason {
case .expired:
"Approval expired"
String(localized: "Approval expired")
case .notFound:
"Approval no longer available"
String(localized: "Approval no longer available")
case .resolved:
"Approval resolved elsewhere"
String(localized: "Approval resolved elsewhere")
case .replaced:
"Approval replaced"
String(localized: "Approval replaced")
case .unavailable:
"Approval unavailable"
String(localized: "Approval unavailable")
}
let terminalOutcomeText = self.recordExecApprovalTerminal(
approvalId: message.approvalId,
@@ -773,7 +787,9 @@ extension WatchInboxStore {
self.execApprovals[index].isResolving = true
self.execApprovals[index].pendingDecision = decision
self.execApprovals[index].activeResolutionAttemptID = attemptID
self.execApprovals[index].statusText = "Sending \(Self.decisionLabel(decision))"
self.execApprovals[index].statusText = String(
format: String(localized: "Sending %@…"),
Self.decisionLabel(decision))
self.execApprovals[index].statusAt = Date()
self.persistState()
return attemptID
@@ -802,16 +818,21 @@ extension WatchInboxStore {
switch result.delivery {
case .delivered:
self.execApprovals[index].isResolving = true
self.execApprovals[index].statusText = "\(Self.decisionLabel(decision)): sent"
self.execApprovals[index].statusText = String(
format: String(localized: "%@: sent"),
Self.decisionLabel(decision))
case .queued:
self.execApprovals[index].isResolving = true
self.execApprovals[index].statusText = "\(Self.decisionLabel(decision)): queued"
self.execApprovals[index].statusText = String(
format: String(localized: "%@: queued"),
Self.decisionLabel(decision))
case .notSent:
// Only a definitive pre-dispatch failure unlocks locally. Uncertain sends stay
// frozen until a canonical retry reset or terminal event arrives.
self.execApprovals[index].isResolving = false
self.execApprovals[index].activeResolutionAttemptID = nil
self.execApprovals[index].statusText = "Couldn't reach iPhone. Tap to retry."
self.execApprovals[index].statusText = String(
localized: "Couldn't reach iPhone. Tap to retry.")
}
self.execApprovals[index].pendingDecision = result.delivery == .notSent ? nil : decision
self.execApprovals[index].statusAt = Date()
@@ -1478,7 +1499,9 @@ extension WatchInboxStore {
func markReplySending(actionLabel: String) {
self.isReplySending = true
self.replyStatusText = "Sending \(actionLabel)"
self.replyStatusText = String(
format: String(localized: "Sending %@…"),
actionLabel)
self.replyStatusAt = Date()
self.persistState()
}
@@ -1486,13 +1509,21 @@ extension WatchInboxStore {
func markReplyResult(_ result: WatchReplySendResult, actionLabel: String) {
self.isReplySending = false
if let errorMessage = result.errorMessage, !errorMessage.isEmpty {
self.replyStatusText = "Failed: \(errorMessage)"
self.replyStatusText = String(
format: String(localized: "Failed: %@"),
errorMessage)
} else if result.deliveredImmediately {
self.replyStatusText = "\(actionLabel): sent"
self.replyStatusText = String(
format: String(localized: "%@: sent"),
actionLabel)
} else if result.queuedForDelivery {
self.replyStatusText = "\(actionLabel): queued"
self.replyStatusText = String(
format: String(localized: "%@: queued"),
actionLabel)
} else {
self.replyStatusText = "\(actionLabel): sent"
self.replyStatusText = String(
format: String(localized: "%@: sent"),
actionLabel)
}
self.replyStatusAt = Date()
self.persistState()
@@ -1529,24 +1560,24 @@ extension WatchInboxStore {
private static func decisionLabel(_ decision: WatchExecApprovalDecision) -> String {
switch decision {
case .allowOnce:
"Allow Once"
String(localized: "Allow Once")
case .deny:
"Deny"
String(localized: "Deny")
}
}
private static func commandLabel(_ command: WatchAppCommand) -> String {
switch command {
case .refresh:
"Refresh"
String(localized: "Refresh")
case .openChat:
"Open Chat"
String(localized: "Open Chat")
case .sendChat:
"Chat"
String(localized: "Chat")
case .startTalk:
"Start Talk"
String(localized: "Start Talk")
case .stopTalk:
"Stop Talk"
String(localized: "Stop Talk")
}
}
+204 -121
View File
@@ -1,6 +1,29 @@
import Foundation
import SwiftUI
import WatchKit
private enum WatchTextValue: ExpressibleByStringLiteral {
case localized(LocalizedStringKey)
case verbatim(String)
init(stringLiteral value: String) {
self = .localized(LocalizedStringKey(value))
}
static func localized(_ value: String) -> Self {
.localized(LocalizedStringKey(value))
}
var text: Text {
switch self {
case let .localized(key):
Text(key)
case let .verbatim(value):
Text(verbatim: value)
}
}
}
struct WatchInboxView: View {
var store: WatchInboxStore
var directNode: WatchDirectNode
@@ -78,8 +101,8 @@ private struct WatchControlSurfaceView: View {
self.pageRail
WatchFaceHeader(
section: "Now",
title: self.greetingText,
subtitle: self.connectionLine,
title: .verbatim(self.greetingText),
subtitle: .verbatim(self.connectionLine),
avatarImageSource: self.avatarImageSource,
avatarText: self.avatarText)
@@ -87,10 +110,10 @@ private struct WatchControlSurfaceView: View {
self.primaryDestination
} label: {
WatchHeroCard(
label: self.primaryLabel,
title: self.primaryTitle,
subtitle: self.primarySubtitle,
accessory: self.store.talkSummaryText)
label: .verbatim(self.primaryLabel),
title: .verbatim(self.primaryTitle),
subtitle: .verbatim(self.primarySubtitle),
accessory: .localized(self.store.talkSummaryText))
}
.buttonStyle(.plain)
@@ -116,7 +139,7 @@ private struct WatchControlSurfaceView: View {
WatchFaceHeader(
section: "Inbox",
title: "What needs you",
subtitle: self.inboxSubtitle,
subtitle: .verbatim(self.inboxSubtitle),
avatarImageSource: self.avatarImageSource,
avatarText: self.avatarText)
@@ -133,7 +156,10 @@ private struct WatchControlSurfaceView: View {
WatchHeroCard(
label: "Clear",
title: "Caught up",
subtitle: self.store.hasAppSnapshot ? "No chats or approvals need you" : "Waiting for iPhone sync",
subtitle: .localized(
self.store.hasAppSnapshot
? "No chats or approvals need you"
: "Waiting for iPhone sync"),
accessory: "Ready")
}
@@ -153,9 +179,9 @@ private struct WatchControlSurfaceView: View {
} label: {
WatchStackCard(
label: "Chat",
title: self.chatPreviewTitle,
subtitle: self.chatPreviewSubtitle,
badge: "\(self.chatCount)",
title: .verbatim(self.chatPreviewTitle),
subtitle: .verbatim(self.chatPreviewSubtitle),
badge: self.chatCount.formatted(),
isProminent: self.approvalCount == 0)
}
.buttonStyle(.plain)
@@ -169,9 +195,9 @@ private struct WatchControlSurfaceView: View {
} label: {
WatchStackCard(
label: "Approvals",
title: self.approvalHeadline,
subtitle: self.approvalSubtitle,
badge: "\(self.approvalCount)",
title: .verbatim(self.approvalHeadline),
subtitle: .verbatim(self.approvalSubtitle),
badge: self.approvalCount.formatted(),
isProminent: true)
}
.buttonStyle(.plain)
@@ -181,13 +207,13 @@ private struct WatchControlSurfaceView: View {
@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)
label: .verbatim(self.store.kind ?? String(localized: "Latest")),
title: .verbatim(self.store.title),
subtitle: .verbatim(self.store.body),
accessory: .verbatim(self.updatedText))
if let details = promptDetails {
WatchDetailText(text: details)
WatchDetailText(text: .verbatim(details))
}
ForEach(self.store.actions) { action in
@@ -217,15 +243,17 @@ private struct WatchControlSurfaceView: View {
private var inboxSubtitle: String {
if self.approvalCount > 0 {
return "Approval waiting"
return String(localized: "Approval waiting")
}
if self.chatCount > 0 {
return self.chatStatusText
}
if self.store.hasMessagePrompt {
return self.store.kind ?? "Latest update"
return self.store.kind ?? String(localized: "Latest update")
}
return self.store.hasAppSnapshot ? "Nothing waiting" : "Waiting for iPhone"
return self.store.hasAppSnapshot
? String(localized: "Nothing waiting")
: String(localized: "Waiting for iPhone")
}
private var approvalsFace: some View {
@@ -233,17 +261,17 @@ private struct WatchControlSurfaceView: View {
self.pageRail
WatchFaceHeader(
section: "Approvals",
title: self.approvalHeadline,
subtitle: self.approvalHeaderSubtitle,
title: .verbatim(self.approvalHeadline),
subtitle: .verbatim(self.approvalHeaderSubtitle),
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))
title: .verbatim(record.approval.commandPreview ?? record.approval.commandText),
subtitle: .verbatim(self.approvalDecisionSubtitle(record)),
accessory: .verbatim(self.approvalAccessory(record)))
if let warningText = WatchExecApprovalDisplay.warningText(record.approval.warningText) {
WatchApprovalWarning(text: warningText)
@@ -269,13 +297,17 @@ private struct WatchControlSurfaceView: View {
WatchHeroCard(
label: "Loading",
title: "Loading approval",
subtitle: self.store.execApprovalReviewStatusText ?? "Waiting for your iPhone",
subtitle: .verbatim(
self.store.execApprovalReviewStatusText
?? String(localized: "Waiting for your iPhone")),
accessory: "Syncing")
} else if self.approvalCount > 0 || self.store.shouldShowExecApprovalReviewStatus {
WatchHeroCard(
label: "Unavailable",
title: "Approval not loaded",
subtitle: self.store.execApprovalReviewStatusText ?? "Approval details have not loaded",
subtitle: .verbatim(
self.store.execApprovalReviewStatusText
?? String(localized: "Approval details have not loaded")),
accessory: "Retry")
WatchSecondaryButton(title: "Review again") {
@@ -285,7 +317,9 @@ private struct WatchControlSurfaceView: View {
WatchHeroCard(
label: "Clear",
title: "No approvals waiting",
subtitle: self.store.lastExecApprovalOutcomeText ?? "You are caught up",
subtitle: .verbatim(
self.store.lastExecApprovalOutcomeText
?? String(localized: "You are caught up")),
accessory: "Ready")
}
@@ -305,18 +339,26 @@ private struct WatchControlSurfaceView: View {
self.pageRail
WatchFaceHeader(
section: "Connection",
title: self.directNode.isConnected ? "Watch node online" : "Direct Gateway",
subtitle: self.directNode.statusText,
title: .verbatim(self.directNode.isConnected
? String(localized: "Watch node online")
: String(localized: "Direct Gateway")),
subtitle: .verbatim(self.directNode.statusText),
avatarImageSource: self.avatarImageSource,
avatarText: self.avatarText)
WatchHeroCard(
label: self.directNode.isConnected ? "Direct" : "Setup",
title: self.directNode.endpointText ?? "Enable from iPhone",
subtitle: self.directNode.isConfigured
? "Uses Wi-Fi or cellular while OpenClaw is active"
: "Open iPhone Settings → Apple Watch",
accessory: self.directNode.isConnected ? "Online" : "Offline")
label: .verbatim(self.directNode.isConnected
? String(localized: "Direct")
: String(localized: "Setup")),
title: .verbatim(
self.directNode.endpointText ?? String(localized: "Enable from iPhone")),
subtitle: .localized(
self.directNode.isConfigured
? "Uses Wi-Fi or cellular while OpenClaw is active"
: "Open iPhone Settings → Apple Watch"),
accessory: .verbatim(self.directNode.isConnected
? String(localized: "Online")
: String(localized: "Offline")))
WatchDetailText(
text: """
@@ -382,23 +424,27 @@ private struct WatchControlSurfaceView: View {
}
private var chatCountText: String {
self.chatCount == 0 ? "0" : "\(self.chatCount)"
self.chatCount.formatted()
}
private var approvalCountText: String {
self.approvalCount == 0 ? "0" : "\(self.approvalCount)"
self.approvalCount.formatted()
}
private var connectionLine: String {
if let snapshot = store.appSnapshot {
return snapshot.gatewayConnected ? "AI agent online" : "Reconnect on iPhone"
return snapshot.gatewayConnected
? String(localized: "AI agent online")
: String(localized: "Reconnect on iPhone")
}
return "Pair iPhone"
return String(localized: "Pair iPhone")
}
private var primaryLabel: String {
if self.store.activeExecApproval != nil { return "Next up" }
return self.store.appSnapshot?.gatewayConnected == true ? "Running" : "Pairing"
if self.store.activeExecApproval != nil { return String(localized: "Next up") }
return self.store.appSnapshot?.gatewayConnected == true
? String(localized: "Running")
: String(localized: "Pairing")
}
private var primaryTitle: String {
@@ -413,66 +459,80 @@ private struct WatchControlSurfaceView: View {
private var primarySubtitle: String {
if self.store.activeExecApproval != nil {
return "Approval waiting on your wrist"
return String(localized: "Approval waiting on your wrist")
}
if self.chatCount > 0 {
return self.chatStatusText
}
return self.store.hasAppSnapshot ? "Ready for quick actions" : "Waiting for iPhone sync"
return self.store.hasAppSnapshot
? String(localized: "Ready for quick actions")
: String(localized: "Waiting for iPhone sync")
}
private var approvalHeadline: String {
self.approvalCount == 1 ? "1 approval waiting" : "\(self.approvalCount) approvals"
if self.approvalCount == 1 {
return String(localized: "1 approval waiting")
}
return String(
format: String(localized: "%@ approvals waiting"),
self.approvalCount.formatted())
}
private var approvalSubtitle: String {
guard let record = store.activeExecApproval else { return "No approvals waiting" }
guard let record = store.activeExecApproval else {
return String(localized: "No approvals waiting")
}
return record.approval.commandPreview ?? record.approval.commandText
}
private var approvalHeaderSubtitle: String {
self.approvalCount > 0 ? "Decide from watch" : "No approvals"
self.approvalCount > 0
? String(localized: "Decide from watch")
: String(localized: "No approvals")
}
private func approvalDecisionSubtitle(_ record: WatchExecApprovalRecord) -> String {
var parts: [String] = []
if let expiresText = expiryText(record.approval.expiresAtMs) {
parts.append("Expires in \(expiresText)")
parts.append(
String(
format: String(localized: "Expires in %@"),
expiresText))
}
if let host = record.approval.host, !host.isEmpty {
parts.append(host)
}
if parts.isEmpty {
parts.append("Review before it runs")
parts.append(String(localized: "Review before it runs"))
}
return parts.joined(separator: " · ")
}
private func approvalAccessory(_ record: WatchExecApprovalRecord) -> String {
if record.isResolving {
return "Sending"
return String(localized: "Sending")
}
if let risk = approvalRiskText(record.approval.risk) {
return risk
}
return "Review"
return String(localized: "Review")
}
private func approvalRiskText(_ risk: WatchRiskLevel?) -> String? {
switch risk {
case .high:
"High risk"
String(localized: "High risk")
case .medium:
"Medium risk"
String(localized: "Medium risk")
case .low:
"Low risk"
String(localized: "Low risk")
case nil:
nil
}
}
private var chatPreviewTitle: String {
guard let item = chatItems.last else { return "No chat synced" }
guard let item = chatItems.last else { return String(localized: "No chat synced") }
return self.roleTitle(item.role)
}
@@ -485,9 +545,16 @@ private struct WatchControlSurfaceView: View {
return status
}
if self.chatCount > 0 {
return self.chatCount == 1 ? "1 recent message" : "\(self.chatCount) recent messages"
if self.chatCount == 1 {
return String(localized: "1 recent message")
}
return String(
format: String(localized: "%@ recent messages"),
self.chatCount.formatted())
}
return self.store.hasAppSnapshot ? "No messages synced" : "Waiting for iPhone"
return self.store.hasAppSnapshot
? String(localized: "No messages synced")
: String(localized: "Waiting for iPhone")
}
private var chatSendStatusText: String? {
@@ -502,9 +569,9 @@ private struct WatchControlSurfaceView: View {
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"
if hour < 12 { return String(localized: "Good morning") }
if hour < 18 { return String(localized: "Good afternoon") }
return String(localized: "Good evening")
}
private var statusLine: String {
@@ -517,20 +584,22 @@ private struct WatchControlSurfaceView: View {
if let replyStatus = store.replyStatusText, !replyStatus.isEmpty {
return replyStatus
}
return self.store.hasAppSnapshot ? "Synced" : "Waiting for iPhone"
return self.store.hasAppSnapshot
? String(localized: "Synced")
: String(localized: "Waiting for iPhone")
}
private var updatedText: String {
guard let updatedAt = store.updatedAt else { return "Just now" }
guard let updatedAt = store.updatedAt else { return String(localized: "Just now") }
return updatedAt.formatted(date: .omitted, time: .shortened)
}
private func roleTitle(_ role: String) -> String {
switch role.lowercased() {
case "user":
"You"
String(localized: "You")
case "system":
"System"
String(localized: "System")
default:
"OpenClaw"
}
@@ -539,11 +608,11 @@ private struct WatchControlSurfaceView: View {
private func actionSubtitle(_ action: WatchPromptAction) -> String {
switch action.style?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "destructive":
"Requires confirmation"
String(localized: "Requires confirmation")
case "cancel":
"Dismiss this update"
String(localized: "Dismiss this update")
default:
"Send from watch"
String(localized: "Send from watch")
}
}
@@ -551,9 +620,11 @@ private struct WatchControlSurfaceView: View {
guard let expiresAtMs else { return nil }
let deltaSeconds = max(0, (expiresAtMs - Int64(Date().timeIntervalSince1970 * 1000)) / 1000)
if deltaSeconds < 60 {
return "<1m"
return String(localized: "less than 1 min")
}
return "\(deltaSeconds / 60)m"
return String(
format: String(localized: "%@ min"),
(deltaSeconds / 60).formatted())
}
}
@@ -685,9 +756,9 @@ private struct WatchClawAvatar: View {
}
private struct WatchFaceHeader: View {
let section: String
let title: String
let subtitle: String
let section: WatchTextValue
let title: WatchTextValue
let subtitle: WatchTextValue
var avatarImageSource: String?
var avatarText: String?
@@ -698,15 +769,15 @@ private struct WatchFaceHeader: View {
imageSource: self.avatarImageSource,
text: self.avatarText)
VStack(alignment: .leading, spacing: 1) {
Text(self.section)
self.section.text
.font(WatchClawType.label(size: 10, weight: .bold))
.foregroundStyle(WatchClawStyle.accent)
.lineLimit(1)
Text(self.title)
self.title.text
.font(WatchClawType.title(size: 18))
.lineLimit(1)
.minimumScaleFactor(0.8)
Text(self.subtitle)
self.subtitle.text
.font(WatchClawType.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -716,29 +787,29 @@ private struct WatchFaceHeader: View {
}
private struct WatchHeroCard: View {
let label: String
let title: String
let subtitle: String
let accessory: String
let label: WatchTextValue
let title: WatchTextValue
let subtitle: WatchTextValue
let accessory: WatchTextValue
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .center) {
Text(self.label)
self.label.text
.font(WatchClawType.label(size: 10, weight: .bold))
.foregroundStyle(WatchClawStyle.accent)
.lineLimit(1)
Spacer(minLength: 4)
Text(self.accessory)
self.accessory.text
.font(WatchClawType.label(size: 10, weight: .semibold))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Text(self.title)
self.title.text
.font(WatchClawType.title(size: 19))
.lineLimit(3)
.minimumScaleFactor(0.75)
Text(self.subtitle)
self.subtitle.text
.font(WatchClawType.body(size: 13))
.foregroundStyle(.secondary)
.lineLimit(2)
@@ -757,10 +828,10 @@ private struct WatchHeroCard: View {
}
private struct WatchDetailText: View {
let text: String
let text: WatchTextValue
var body: some View {
Text(self.text)
self.text.text
.font(WatchClawType.body(size: 12))
.foregroundStyle(.secondary)
.lineLimit(5)
@@ -801,7 +872,7 @@ private struct WatchCompactStatusStrip: View {
}
private struct WatchCompactMetric: View {
let label: String
let label: LocalizedStringKey
let value: String
var body: some View {
@@ -898,23 +969,23 @@ private struct WatchSecondaryButton: View {
}
private struct WatchStackCard: View {
let label: String
let title: String
let subtitle: String
let label: WatchTextValue
let title: WatchTextValue
let subtitle: WatchTextValue
let badge: String?
var isProminent = false
var body: some View {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(self.label)
self.label.text
.font(WatchClawType.label(size: 10, weight: .bold))
.foregroundStyle(WatchClawStyle.accent)
.lineLimit(1)
Text(self.title)
self.title.text
.font(WatchClawType.title(size: 17))
.lineLimit(1)
Text(self.subtitle)
self.subtitle.text
.font(WatchClawType.body(size: 13))
.foregroundStyle(.secondary)
.lineLimit(2)
@@ -959,8 +1030,8 @@ private struct WatchActionCard: View {
Button(action: self.action) {
WatchStackCard(
label: "OpenClaw",
title: self.title,
subtitle: self.subtitle,
title: .verbatim(self.title),
subtitle: .verbatim(self.subtitle),
badge: nil)
}
.buttonStyle(.plain)
@@ -1053,7 +1124,7 @@ private enum WatchExecApprovalDisplay {
if !statusText.isEmpty {
return statusText
}
return record.isResolving ? "Sending decision..." : nil
return record.isResolving ? String(localized: "Sending decision...") : nil
}
}
@@ -1105,9 +1176,9 @@ private struct WatchChatBubble: View {
private var roleTitle: String {
switch self.item.role.lowercased() {
case "user":
"You"
String(localized: "You")
case "system":
"System"
String(localized: "System")
default:
"OpenClaw"
}
@@ -1207,10 +1278,10 @@ private struct WatchChatTimelineView: View {
private var voiceStatusText: String? {
if self.speechPlayback.isSpeaking {
return "Speaking reply…"
return String(localized: "Speaking reply…")
}
if self.voiceTurnTracker.isAwaitingReply {
return "Waiting for spoken reply…"
return String(localized: "Waiting for spoken reply…")
}
return nil
}
@@ -1367,12 +1438,12 @@ private struct WatchChatComposer: View {
private var voiceButtonAccessibilityLabel: String {
if self.isSpeaking {
return "Stop speaking"
return String(localized: "Stop speaking")
}
if self.isAwaitingVoiceReply {
return "Cancel voice turn"
return String(localized: "Cancel voice turn")
}
return "Start voice turn"
return String(localized: "Start voice turn")
}
}
@@ -1417,7 +1488,9 @@ private struct WatchExecApprovalListView: View {
WatchHeroCard(
label: "Clear",
title: "No approvals waiting",
subtitle: self.store.lastExecApprovalOutcomeText ?? "You are caught up",
subtitle: .verbatim(
self.store.lastExecApprovalOutcomeText
?? String(localized: "You are caught up")),
accessory: "Ready")
} else {
ForEach(self.store.sortedExecApprovals) { record in
@@ -1429,8 +1502,8 @@ private struct WatchExecApprovalListView: View {
} label: {
WatchStackCard(
label: "Approval",
title: record.approval.commandPreview ?? record.approval.commandText,
subtitle: self.metadataLine(for: record),
title: .verbatim(record.approval.commandPreview ?? record.approval.commandText),
subtitle: .verbatim(self.metadataLine(for: record)),
badge: nil)
}
.buttonStyle(.plain)
@@ -1457,16 +1530,18 @@ private struct WatchExecApprovalListView: View {
if let statusText = record.statusText, !statusText.isEmpty {
parts.append(statusText)
}
return parts.isEmpty ? "Pending review" : parts.joined(separator: " · ")
return parts.isEmpty ? String(localized: "Pending review") : parts.joined(separator: " · ")
}
private static func expiresText(_ expiresAtMs: Int64?) -> String? {
guard let expiresAtMs else { return nil }
let deltaSeconds = max(0, (expiresAtMs - Int64(Date().timeIntervalSince1970 * 1000)) / 1000)
if deltaSeconds < 60 {
return "Expires in <1m"
return String(localized: "Expires in less than 1 min")
}
return "Expires in \(deltaSeconds / 60)m"
return String(
format: String(localized: "Expires in %@ min"),
(deltaSeconds / 60).formatted())
}
}
@@ -1478,11 +1553,15 @@ private struct WatchExecApprovalDetailView: View {
var body: some View {
WatchDetailScroll(title: "Review Command") {
WatchHeroCard(
label: self.riskText(self.currentRecord?.approval.risk ?? self.record.approval.risk) ?? "Review",
label: .verbatim(
self.riskText(self.currentRecord?.approval.risk ?? self.record.approval.risk)
?? String(localized: "Review")),
title: "Command execution",
subtitle: self.metadataSummary,
accessory: Self
.expiresText(self.currentRecord?.approval.expiresAtMs ?? self.record.approval.expiresAtMs) ?? "Now")
subtitle: .verbatim(self.metadataSummary),
accessory: .verbatim(
Self.expiresText(
self.currentRecord?.approval.expiresAtMs ?? self.record.approval.expiresAtMs)
?? String(localized: "Now")))
WatchApprovalCommandReview(commandText: self.commandText)
@@ -1552,17 +1631,19 @@ private struct WatchExecApprovalDetailView: View {
if let agentId = approval.agentId, !agentId.isEmpty {
parts.append(agentId)
}
return parts.isEmpty ? "Review command below" : parts.joined(separator: " · ")
return parts.isEmpty
? String(localized: "Review command below")
: parts.joined(separator: " · ")
}
private func riskText(_ risk: WatchRiskLevel?) -> String? {
switch risk {
case .high:
"High risk"
String(localized: "High risk")
case .medium:
"Medium risk"
String(localized: "Medium risk")
case .low:
"Low risk"
String(localized: "Low risk")
case nil:
nil
}
@@ -1572,14 +1653,16 @@ private struct WatchExecApprovalDetailView: View {
guard let expiresAtMs else { return nil }
let deltaSeconds = max(0, (expiresAtMs - Int64(Date().timeIntervalSince1970 * 1000)) / 1000)
if deltaSeconds < 60 {
return "<1 minute"
return String(localized: "less than 1 minute")
}
return "\(deltaSeconds / 60) minutes"
return String(
format: String(localized: "%@ minutes"),
(deltaSeconds / 60).formatted())
}
}
private struct WatchDetailScroll<Content: View>: View {
let title: String
let title: LocalizedStringKey
@ViewBuilder var content: Content
var body: some View {