From ee3104340d8dc3d29023b408da5deb75d381f2c9 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:17:13 +0200 Subject: [PATCH] fix(ios): localize design and chat surfaces --- .../Design/AgentProDreamingDestination.swift | 17 +- .../Design/AgentProNodesDestination.swift | 68 ++++--- .../ios/Sources/Design/AgentProTab+Cron.swift | 14 +- .../Design/AgentProTab+Destinations.swift | 4 +- .../Sources/Design/AgentProTab+Overview.swift | 185 ++++++++++++------ .../Sources/Design/AgentProTab+Skills.swift | 60 ++++-- .../Sources/Design/AgentProTab+Usage.swift | 11 +- apps/ios/Sources/Design/AgentProTab.swift | 16 +- .../Design/AgentWorkspaceFilesScreen.swift | 7 +- apps/ios/Sources/Design/ChatProTab.swift | 8 +- .../Sources/Design/CommandCenterSupport.swift | 46 +++-- .../ios/Sources/Design/CommandCenterTab.swift | 83 +++++--- .../Sources/Design/IPadActivityScreen.swift | 13 +- .../Design/IPadSidebarFeaturePreviews.swift | 12 +- .../Design/IPadSidebarScreenChrome.swift | 4 +- .../Design/IPadSkillWorkshopScreen.swift | 46 +++-- .../Sources/Design/IPadWorkboardScreen.swift | 127 +++++++++--- .../Design/OpenClawProComponents.swift | 112 +++++++---- .../Design/RootTabsPhoneControlHub.swift | 11 +- .../Sources/OpenClawChatUI/ChatComposer.swift | 5 +- .../OpenClawChatUI/ChatContextUsage.swift | 34 +++- .../OpenClawChatUI/ChatLinkPreview.swift | 14 +- .../OpenClawChatUI/ChatMessageViews.swift | 8 +- .../ChatViewModel+Attachments.swift | 15 +- .../OpenClawChatUI/ChatWindowShell.swift | 10 +- .../OpenClawChatUI/VoiceNoteRecorder.swift | 4 +- 26 files changed, 650 insertions(+), 284 deletions(-) diff --git a/apps/ios/Sources/Design/AgentProDreamingDestination.swift b/apps/ios/Sources/Design/AgentProDreamingDestination.swift index 470f8096e7db..f34dcafce937 100644 --- a/apps/ios/Sources/Design/AgentProDreamingDestination.swift +++ b/apps/ios/Sources/Design/AgentProDreamingDestination.swift @@ -65,7 +65,7 @@ struct AgentProDreamingDestination: View { if let headerLeadingAction { OpenClawAdaptiveHeaderRow( title: "Dreaming", - subtitle: self.dreamingDetail, + subtitle: .localized(self.dreamingDetail), titleFont: OpenClawType.title3SemiBold, subtitleFont: OpenClawType.subheadMedium) { @@ -253,7 +253,10 @@ struct AgentProDreamingDestination: View { icon: "book.closed", title: diary.found ? "Dream diary is empty" : "No dream diary yet", detail: diary.found - ? "\(diary.path) exists but has no readable content." + ? .verbatim(String( + format: String( + localized: "%@ exists but has no readable content."), + diary.path)) : "The gateway did not find DREAMS.md or dreams.md in the active agent workspace.") .padding(14) } @@ -308,7 +311,11 @@ struct AgentProDreamingDestination: View { .font(OpenClawType.subheadSemiBold) .lineLimit(1) Spacer(minLength: 8) - Text("\(day.entryCount) \(day.entryCount == 1 ? "entry" : "entries")") + Text(verbatim: day.entryCount == 1 + ? String(localized: "1 entry") + : String( + format: String(localized: "%@ entries"), + day.entryCount.formatted())) .font(OpenClawType.caption2SemiBold) .foregroundStyle(OpenClawBrand.accent) } @@ -341,7 +348,7 @@ struct AgentProDreamingDestination: View { emptyDetail: String) -> some View { VStack(alignment: .leading, spacing: 8) { - ProSectionHeader(title: title) + ProSectionHeader(title: .localized(title)) ProCard(padding: 0) { if entries.isEmpty { self.emptyDetailRow( @@ -382,7 +389,7 @@ struct AgentProDreamingDestination: View { .lineLimit(1) } Spacer(minLength: 8) - Text("\(entry.totalSignalCount)") + Text(verbatim: entry.totalSignalCount.formatted()) .font(OpenClawType.caption2SemiBold) .foregroundStyle(OpenClawBrand.accent) .lineLimit(1) diff --git a/apps/ios/Sources/Design/AgentProNodesDestination.swift b/apps/ios/Sources/Design/AgentProNodesDestination.swift index 5ea6b1a85c28..a9af709476fb 100644 --- a/apps/ios/Sources/Design/AgentProNodesDestination.swift +++ b/apps/ios/Sources/Design/AgentProNodesDestination.swift @@ -39,7 +39,7 @@ struct AgentProNodesDestination: View { if let headerLeadingAction { OpenClawAdaptiveHeaderRow( title: "Instances", - subtitle: self.instancesDetail, + subtitle: .verbatim(self.instancesDetail), titleFont: OpenClawType.title3SemiBold, subtitleFont: OpenClawType.subheadMedium) { @@ -58,7 +58,7 @@ struct AgentProNodesDestination: View { VStack(alignment: .leading, spacing: 3) { Text("Instances") .font(OpenClawType.headline) - Text(self.instancesDetail) + Text(verbatim: self.instancesDetail) .font(OpenClawType.caption) .foregroundStyle(.secondary) } @@ -79,9 +79,15 @@ struct AgentProNodesDestination: View { ProValuePill(value: self.instancesValue, color: self.instancesColor) } HStack(spacing: 10) { - self.detailMetric(label: "Connected", value: "\(self.overview?.presence.count ?? 0)") - self.detailMetric(label: "Agents", value: "\(self.agentCount)") - self.detailMetric(label: "Gateway", value: self.gatewayConnected ? "online" : "offline") + self.detailMetric( + label: "Connected", + value: (self.overview?.presence.count ?? 0).formatted()) + self.detailMetric(label: "Agents", value: self.agentCount.formatted()) + self.detailMetric( + label: "Gateway", + value: self.gatewayConnected + ? String(localized: "online") + : String(localized: "offline")) } } } @@ -134,7 +140,7 @@ struct AgentProNodesDestination: View { HStack(alignment: .top, spacing: 12) { ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry)) VStack(alignment: .leading, spacing: 4) { - Text(Self.presenceLabel(entry) ?? "Instance") + Text(verbatim: Self.presenceLabel(entry) ?? String(localized: "Instance")) .font(OpenClawType.subheadSemiBold) .lineLimit(1) Text(Self.presenceDetail(entry)) @@ -173,7 +179,7 @@ struct AgentProNodesDestination: View { HStack(spacing: 12) { ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry)) VStack(alignment: .leading, spacing: 3) { - Text(Self.presenceLabel(entry) ?? "Instance") + Text(verbatim: Self.presenceLabel(entry) ?? String(localized: "Instance")) .font(OpenClawType.headline) Text(Self.presenceDetail(entry)) .font(OpenClawType.caption) @@ -213,18 +219,18 @@ struct AgentProNodesDestination: View { } .safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset) } - .navigationTitle(Self.presenceLabel(entry) ?? "Instance") + .navigationTitle(Self.presenceLabel(entry) ?? String(localized: "Instance")) .navigationBarTitleDisplayMode(.inline) } - private func nodeDetailRow(_ title: String, value: String?) -> some View { + private func nodeDetailRow(_ title: OpenClawTextValue, value: String?) -> some View { let normalized = Self.normalized(value) ?? "n/a" return HStack(spacing: 10) { - Text(title) + title.text .font(OpenClawType.subhead) .foregroundStyle(.secondary) Spacer(minLength: 8) - Text(normalized) + Text(verbatim: normalized) .font(OpenClawType.subhead) .lineLimit(1) .truncationMode(.middle) @@ -235,13 +241,13 @@ struct AgentProNodesDestination: View { } .buttonStyle(.plain) .disabled(normalized == "n/a") - .accessibilityLabel("Copy \(title)") + .accessibilityLabel("Copy value") } .font(OpenClawType.subhead) .padding(.vertical, 10) } - private func nodeListCard(title: String, values: [String]) -> some View { + private func nodeListCard(title: OpenClawTextValue, values: [String]) -> some View { VStack(alignment: .leading, spacing: 8) { ProSectionHeader(title: title) ProCard { @@ -253,7 +259,7 @@ struct AgentProNodesDestination: View { } else { VStack(alignment: .leading, spacing: 8) { ForEach(values, id: \.self) { value in - Text(value) + Text(verbatim: value) .font(OpenClawType.monoSmall) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) @@ -265,12 +271,12 @@ struct AgentProNodesDestination: View { } } - private func detailMetric(label: String, value: String) -> some View { + private func detailMetric(label: OpenClawTextValue, value: String) -> some View { VStack(alignment: .leading, spacing: 3) { - Text(label) + label.text .font(OpenClawType.caption2Medium) .foregroundStyle(.secondary) - Text(value) + Text(verbatim: value) .font(OpenClawType.subheadSemiBold) .lineLimit(1) .minimumScaleFactor(0.8) @@ -282,13 +288,17 @@ struct AgentProNodesDestination: View { in: RoundedRectangle(cornerRadius: OpenClawRadius.sm, style: .continuous)) } - private func emptyRow(icon: String, title: String, detail: String) -> some View { + private func emptyRow( + icon: String, + title: OpenClawTextValue, + detail: OpenClawTextValue) -> some View + { HStack(spacing: 12) { ProIconBadge(systemName: icon, color: .secondary) VStack(alignment: .leading, spacing: 3) { - Text(title) + title.text .font(OpenClawType.subheadSemiBold) - Text(detail) + detail.text .font(OpenClawType.caption) .foregroundStyle(.secondary) .lineLimit(2) @@ -313,7 +323,7 @@ struct AgentProNodesDestination: View { if !parts.isEmpty { return parts.joined(separator: " • ") } - return Self.normalized(entry.text) ?? "Presence beacon received." + return Self.normalized(entry.text) ?? String(localized: "Presence beacon received.") } private static func presenceMeta(_ entry: PresenceEntry) -> String? { @@ -321,10 +331,20 @@ struct AgentProNodesDestination: View { let scopesCount = entry.scopes?.count ?? 0 let rolesCount = entry.roles?.count ?? 0 let labels = [ - Self.normalized(entry.instanceid).map { "instance \($0)" }, + Self.normalized(entry.instanceid).map { + String(format: String(localized: "instance %@"), $0) + }, tags.isEmpty ? nil : tags, - scopesCount > 0 ? "\(scopesCount) scopes" : nil, - rolesCount > 0 ? "\(rolesCount) roles" : nil, + scopesCount > 0 + ? String( + format: String(localized: "%@ scopes"), + scopesCount.formatted()) + : nil, + rolesCount > 0 + ? String( + format: String(localized: "%@ roles"), + rolesCount.formatted()) + : nil, ].compactMap(\.self) return labels.isEmpty ? nil : labels.joined(separator: " • ") } diff --git a/apps/ios/Sources/Design/AgentProTab+Cron.swift b/apps/ios/Sources/Design/AgentProTab+Cron.swift index 9e31bb66e8e5..c7509700e4c6 100644 --- a/apps/ios/Sources/Design/AgentProTab+Cron.swift +++ b/apps/ios/Sources/Design/AgentProTab+Cron.swift @@ -131,7 +131,12 @@ extension AgentProTab { @MainActor func runCronJob(_ job: CronJob) async { - await self.runCronAction(job, success: "Queued \(job.name).") { + await self.runCronAction( + job, + success: String( + format: String(localized: "Queued %@."), + job.name)) + { let params = CronRunParams(id: job.id, mode: "force") _ = try await self.requestGateway(method: "cron.run", params: params, timeoutSeconds: 20) } @@ -139,7 +144,12 @@ extension AgentProTab { @MainActor func setCronJob(_ job: CronJob, enabled: Bool) async { - await self.runCronAction(job, success: enabled ? "Enabled \(job.name)." : "Paused \(job.name).") { + let success = String( + format: enabled + ? String(localized: "Enabled %@.") + : String(localized: "Paused %@."), + job.name) + await self.runCronAction(job, success: success) { let params = CronUpdateParams(id: job.id, patch: CronUpdatePatch(enabled: enabled)) _ = try await self.requestGateway(method: "cron.update", params: params, timeoutSeconds: 20) } diff --git a/apps/ios/Sources/Design/AgentProTab+Destinations.swift b/apps/ios/Sources/Design/AgentProTab+Destinations.swift index d30e8b97a788..4e5bbdf14471 100644 --- a/apps/ios/Sources/Design/AgentProTab+Destinations.swift +++ b/apps/ios/Sources/Design/AgentProTab+Destinations.swift @@ -182,8 +182,8 @@ extension AgentProTab { func directHeader(for route: AgentRoute, title: String, subtitle: String) -> some View { if let headerLeadingAction = self.directHeaderLeadingAction(for: route) { OpenClawAdaptiveHeaderRow( - title: title, - subtitle: subtitle, + title: .localized(title), + subtitle: .localized(subtitle), titleFont: OpenClawType.title3SemiBold, subtitleFont: OpenClawType.subheadMedium) { diff --git a/apps/ios/Sources/Design/AgentProTab+Overview.swift b/apps/ios/Sources/Design/AgentProTab+Overview.swift index e26ceabba3de..f0a87e764210 100644 --- a/apps/ios/Sources/Design/AgentProTab+Overview.swift +++ b/apps/ios/Sources/Design/AgentProTab+Overview.swift @@ -6,8 +6,8 @@ extension AgentProTab { var rosterHeader: some View { VStack(alignment: .leading, spacing: 10) { OpenClawAdaptiveHeaderRow( - title: self.headerTitle, - subtitle: "\(self.sortedAgents.count) total", + title: .localized(self.headerTitle), + subtitle: .verbatim(self.agentTotalText), titleFont: OpenClawType.title2SemiBold, subtitleFont: OpenClawType.subheadMedium, subtitleLineLimit: 1) @@ -132,7 +132,9 @@ extension AgentProTab { Image(systemName: self.gatewayConnected ? "antenna.radiowaves.left.and.right" : "wifi.slash") } .tint(self.gatewayConnected ? OpenClawBrand.ok : .secondary) - .accessibilityLabel(self.gatewayConnected ? "Gateway online" : "Gateway offline") + .accessibilityLabel(self.gatewayConnected + ? String(localized: "Gateway online") + : String(localized: "Gateway offline")) .accessibilityHint("Opens Settings / Gateway") } } @@ -169,28 +171,28 @@ extension AgentProTab { icon: "sparkles", title: "Skills", value: self.skillsValue, - detail: self.skillsDetail, + detail: .verbatim(self.skillsDetail), color: self.gatewayConnected ? OpenClawBrand.accent : .secondary, route: .skills) self.metricTile( icon: "externaldrive.connected.to.line.below", title: "Instances", value: self.instancesValue, - detail: self.instancesDetail, + detail: .verbatim(self.instancesDetail), color: self.instancesColor, route: .instances) self.metricTile( icon: "clock.arrow.circlepath", title: "Cron", value: self.cronValue, - detail: self.cronDetail, + detail: .verbatim(self.cronDetail), color: self.cronColor, route: .cron) self.metricTile( icon: "chart.line.uptrend.xyaxis", title: "Usage", value: self.usageValue, - detail: self.usageDetail, + detail: .verbatim(self.usageDetail), color: self.gatewayConnected ? OpenClawBrand.accent : .secondary, route: .usage) self.metricTile( @@ -220,7 +222,7 @@ extension AgentProTab { self.agentMenuRow( icon: "moon", title: "Dreaming", - detail: self.dreamingDetail, + detail: .verbatim(self.dreamingDetail), value: self.dreamingValue, color: self.dreamingColor, showsChevron: true) @@ -312,7 +314,9 @@ extension AgentProTab { } .buttonStyle(.plain) .accessibilityLabel(agentAccessibilityLabel(agent, isActive: isActive, state: state)) - .accessibilityHint(isActive ? "Selected agent" : "Selects this agent") + .accessibilityHint(isActive + ? String(localized: "Selected agent") + : String(localized: "Selects this agent")) } func headerIconButton( @@ -352,8 +356,8 @@ extension AgentProTab { func agentMenuRow( icon: String, - title: String, - detail: String, + title: OpenClawTextValue, + detail: OpenClawTextValue, value: String, color: Color, showsChevron: Bool = false) -> some View @@ -361,9 +365,9 @@ extension AgentProTab { HStack(spacing: 12) { ProIconBadge(systemName: icon, color: color) VStack(alignment: .leading, spacing: 3) { - Text(title) + title.text .font(OpenClawType.subheadSemiBold) - Text(detail) + detail.text .font(OpenClawType.caption) .foregroundStyle(.secondary) .lineLimit(1) @@ -384,9 +388,9 @@ extension AgentProTab { func metricTile( icon: String, - title: String, + title: OpenClawTextValue, value: String, - detail: String, + detail: OpenClawTextValue, color: Color, route: AgentRoute? = nil) -> some View { @@ -435,9 +439,9 @@ extension AgentProTab { } } VStack(alignment: .leading, spacing: 2) { - Text(title) + title.text .font(OpenClawType.captionSemiBold) - Text(detail) + detail.text .font(OpenClawType.caption2) .foregroundStyle(.secondary) .lineLimit(2) @@ -453,7 +457,9 @@ extension AgentProTab { HStack(spacing: 12) { ProIconBadge(systemName: "clock.badge.questionmark", color: .secondary) VStack(alignment: .leading, spacing: 3) { - Text(self.gatewayConnected ? "No scheduled jobs" : "Cron unavailable") + Text(self.gatewayConnected + ? LocalizedStringKey("No scheduled jobs") + : LocalizedStringKey("Cron unavailable")) .font(OpenClawType.subheadSemiBold) Text(self.gatewayConnected ? "The gateway has no visible cron jobs." @@ -541,19 +547,31 @@ extension AgentProTab { } var emptyAgentsTitle: String { - if !self.gatewayConnected { return "Agents unavailable" } - if !agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "No matches" } - if agentRosterFilter != .all { return "No \(agentRosterFilter.title.lowercased()) agents" } - return "No agents reported" + if !self.gatewayConnected { return String(localized: "Agents unavailable") } + if !agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return String(localized: "No matches") + } + switch agentRosterFilter { + case .online: + return String(localized: "No online agents") + case .ready: + return String(localized: "No ready agents") + case .all: + return String(localized: "No agents reported") + } } var emptyAgentsDetail: String { - if !self.gatewayConnected { return "Connect a gateway to load the live agent roster." } - if !agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return "Try another search or clear the agent filters." + if !self.gatewayConnected { + return String(localized: "Connect a gateway to load the live agent roster.") } - if agentRosterFilter != .all { return "Clear the filter to view the full roster." } - return "The connected gateway did not return an agent list." + if !agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return String(localized: "Try another search or clear the agent filters.") + } + if agentRosterFilter != .all { + return String(localized: "Clear the filter to view the full roster.") + } + return String(localized: "The connected gateway did not return an agent list.") } var overviewTaskID: String { @@ -566,69 +584,102 @@ extension AgentProTab { } var skillsValue: String { - guard self.gatewayConnected else { return "offline" } + guard self.gatewayConnected else { return String(localized: "offline") } guard let skills = overview?.skills else { - return overviewLoading ? "..." : "live" + return overviewLoading ? "..." : String(localized: "live") } return "\(skills.enabledCount)/\(skills.totalCount)" } var skillsDetail: String { - guard self.gatewayConnected else { return "Connect a gateway to load skills." } + guard self.gatewayConnected else { + return String(localized: "Connect a gateway to load skills.") + } guard let skills = overview?.skills else { - return overviewLoading ? "Loading skill status." : "Skill status is available from the gateway." + return overviewLoading + ? String(localized: "Loading skill status.") + : String(localized: "Skill status is available from the gateway.") } if skills.blockedCount > 0 { - return "\(skills.enabledCount) enabled, \(skills.blockedCount) blocked" + return String( + format: String(localized: "%@ enabled, %@ blocked"), + skills.enabledCount.formatted(), + skills.blockedCount.formatted()) } if skills.missingRequirementCount > 0 { - return "\(skills.enabledCount) enabled, \(skills.missingRequirementCount) need setup" + return String( + format: String(localized: "%@ enabled, %@ need setup"), + skills.enabledCount.formatted(), + skills.missingRequirementCount.formatted()) } - return "\(skills.enabledCount) enabled, \(skills.totalCount) installed" + return String( + format: String(localized: "%@ enabled, %@ installed"), + skills.enabledCount.formatted(), + skills.totalCount.formatted()) } var instancesValue: String { - guard self.gatewayConnected else { return "offline" } + guard self.gatewayConnected else { return String(localized: "offline") } guard let count = overview?.presence.count else { - return overviewLoading ? "..." : "live" + return overviewLoading ? "..." : String(localized: "live") } return "\(count)" } var instancesDetail: String { - guard self.gatewayConnected else { return "Connect a gateway to load instances." } + guard self.gatewayConnected else { + return String(localized: "Connect a gateway to load instances.") + } guard let presence = overview?.presence else { - return overviewLoading ? "Loading instance presence." : "Instance presence is available." + return overviewLoading + ? String(localized: "Loading instance presence.") + : String(localized: "Instance presence is available.") } let labels = presence.prefix(2).compactMap(presenceLabel) if labels.isEmpty { - return "No live instances reported." + return String(localized: "No live instances reported.") } return labels.joined(separator: ", ") } + private var agentTotalText: String { + let count = self.sortedAgents.count + if count == 1 { + return String(localized: "1 agent total") + } + return String(format: String(localized: "%@ agents total"), count.formatted()) + } + var instancesColor: Color { guard self.gatewayConnected else { return .secondary } return (overview?.presence.isEmpty == false) ? OpenClawBrand.accent : .secondary } var cronValue: String { - guard self.gatewayConnected else { return "offline" } + guard self.gatewayConnected else { return String(localized: "offline") } guard let cronStatus = overview?.cronStatus else { - return overviewLoading ? "..." : "live" + return overviewLoading ? "..." : String(localized: "live") } - return cronStatus.enabled ? "\(cronStatus.jobs)" : "off" + return cronStatus.enabled ? cronStatus.jobs.formatted() : String(localized: "off") } var cronDetail: String { - guard self.gatewayConnected else { return "Connect a gateway to load cron." } + guard self.gatewayConnected else { + return String(localized: "Connect a gateway to load cron.") + } guard let cronStatus = overview?.cronStatus else { - return overviewLoading ? "Loading cron status." : "Cron status is available." + return overviewLoading + ? String(localized: "Loading cron status.") + : String(localized: "Cron status is available.") } if let nextWakeAtMs = cronStatus.nextwakeatms { - return "Next wake \(Self.relativeTime(fromMilliseconds: nextWakeAtMs))" + return String( + format: String(localized: "Next wake %@"), + Self.relativeTime(fromMilliseconds: nextWakeAtMs)) } - return cronStatus.enabled ? "Scheduler enabled" : "Scheduler disabled" + return cronStatus.enabled + ? String(localized: "Scheduler enabled") + : String(localized: "Scheduler disabled") } var cronColor: Color { @@ -637,7 +688,7 @@ extension AgentProTab { } var usageValue: String { - guard self.gatewayConnected else { return "offline" } + guard self.gatewayConnected else { return String(localized: "offline") } guard let usage = overview?.usage else { return overviewLoading ? "..." : "7d" } @@ -651,33 +702,51 @@ extension AgentProTab { } var usageDetail: String { - guard self.gatewayConnected else { return "Connect a gateway to load usage." } + guard self.gatewayConnected else { + return String(localized: "Connect a gateway to load usage.") + } guard let usage = overview?.usage else { - return overviewLoading ? "Loading recent usage." : "Recent usage is available." + return overviewLoading + ? String(localized: "Loading recent usage.") + : String(localized: "Recent usage is available.") } if let tokens = usage.totalTokens, tokens > 0 { - return "\(Self.compactNumber(tokens)) tokens in \(usage.days ?? 7)d" + return String( + format: String(localized: "%@ tokens in %@d"), + Self.compactNumber(tokens), + (usage.days ?? 7).formatted()) } - return "No token usage reported for \(usage.days ?? 7)d." + return String( + format: String(localized: "No token usage reported for %@d."), + (usage.days ?? 7).formatted()) } var dreamingValue: String { - guard self.gatewayConnected else { return "offline" } + guard self.gatewayConnected else { return String(localized: "offline") } guard let dreaming = overview?.dreaming else { - return overviewLoading ? "..." : "live" + return overviewLoading ? "..." : String(localized: "live") } - return dreaming.enabled ? "on" : "off" + return dreaming.enabled ? String(localized: "on") : String(localized: "off") } var dreamingDetail: String { - guard self.gatewayConnected else { return "Connect a gateway to load dreaming." } + guard self.gatewayConnected else { + return String(localized: "Connect a gateway to load dreaming.") + } guard let dreaming = overview?.dreaming else { - return overviewLoading ? "Loading dreaming status." : "Background memory status is available." + return overviewLoading + ? String(localized: "Loading dreaming status.") + : String(localized: "Background memory status is available.") } if let nextRunAtMs = dreaming.nextRunAtMs { - return "Next cycle \(Self.relativeTime(fromMilliseconds: nextRunAtMs))" + return String( + format: String(localized: "Next cycle %@"), + Self.relativeTime(fromMilliseconds: nextRunAtMs)) } - return "\(dreaming.totalSignalCount ?? 0) signals, \(dreaming.promotedToday ?? 0) promoted today" + return String( + format: String(localized: "%@ signals, %@ promoted today"), + (dreaming.totalSignalCount ?? 0).formatted(), + (dreaming.promotedToday ?? 0).formatted()) } var dreamingColor: Color { diff --git a/apps/ios/Sources/Design/AgentProTab+Skills.swift b/apps/ios/Sources/Design/AgentProTab+Skills.swift index 7444f108d2a8..a6076ff3e468 100644 --- a/apps/ios/Sources/Design/AgentProTab+Skills.swift +++ b/apps/ios/Sources/Design/AgentProTab+Skills.swift @@ -181,7 +181,10 @@ extension AgentProTab { .buttonStyle(.bordered) .controlSize(.small) .disabled(installing || !self.skillConfigBusyKeys.isEmpty) - .accessibilityLabel("Install \(result.displayName)") + .accessibilityLabel( + String( + format: String(localized: "Install %@"), + result.displayName)) } .padding(.vertical, 10) } @@ -293,18 +296,24 @@ extension AgentProTab { Text(skill.displayName) .font(OpenClawType.subheadSemiBold) .lineLimit(1) - Text(self.normalized(skill.description) ?? self.normalized(skill.source) ?? "Workspace skill") + Text(verbatim: self.normalized(skill.description) + ?? self.normalized(skill.source) + ?? String(localized: "Workspace skill")) .font(OpenClawType.caption) .foregroundStyle(.secondary) .lineLimit(2) if let missing = skill.missingSummary { - Text("Missing: \(missing)") + Text(verbatim: String( + format: String(localized: "Missing: %@"), + missing)) .font(OpenClawType.caption2) .foregroundStyle(OpenClawBrand.warn) .lineLimit(1) } if let install = skill.installSummary { - Text("Setup: \(install)") + Text(verbatim: String( + format: String(localized: "Setup: %@"), + install)) .font(OpenClawType.caption2) .foregroundStyle(.secondary) .lineLimit(1) @@ -312,7 +321,7 @@ extension AgentProTab { } Spacer(minLength: 8) VStack(alignment: .trailing, spacing: 6) { - self.skillToggle(skill, title: status.text) + self.skillToggle(skill, title: self.localizedSkillStatus(status.text)) HStack(spacing: 6) { if self.canInstallSkillRequirements(skill) { Button { @@ -323,7 +332,10 @@ extension AgentProTab { .buttonStyle(.bordered) .controlSize(.mini) .disabled(self.isSkillConfigBusy(skill)) - .accessibilityLabel("Set up \(skill.displayName)") + .accessibilityLabel( + String( + format: String(localized: "Set up %@"), + skill.displayName)) } Button { self.openSkillEditor(skill) @@ -332,9 +344,14 @@ extension AgentProTab { } .buttonStyle(.bordered) .controlSize(.mini) - .accessibilityLabel("Edit \(skill.displayName)") + .accessibilityLabel( + String( + format: String(localized: "Edit %@"), + skill.displayName)) } - Text(busy ? "saving" : status.text) + Text(verbatim: busy + ? String(localized: "saving") + : self.localizedSkillStatus(status.text)) .font(OpenClawType.caption2SemiBold) .foregroundStyle(status.color) .lineLimit(1) @@ -458,13 +475,15 @@ extension AgentProTab { VStack(alignment: .leading, spacing: 3) { Text(skill.displayName) .font(OpenClawType.headline) - Text(self.normalized(skill.description) ?? self.normalized(skill.source) ?? "Workspace skill") + Text(verbatim: self.normalized(skill.description) + ?? self.normalized(skill.source) + ?? String(localized: "Workspace skill")) .font(OpenClawType.caption) .foregroundStyle(.secondary) .lineLimit(3) } Spacer(minLength: 8) - ProValuePill(value: status.text, color: status.color) + ProValuePill(value: self.localizedSkillStatus(status.text), color: status.color) } } .padding(.horizontal, OpenClawProMetric.pagePadding) @@ -561,7 +580,9 @@ extension AgentProTab { Text("Setup") .font(OpenClawType.headline) if let missing = skill.missingSummary { - Text("Missing: \(missing)") + Text(verbatim: String( + format: String(localized: "Missing: %@"), + missing)) .font(OpenClawType.caption) .foregroundStyle(OpenClawBrand.warn) } else { @@ -849,4 +870,21 @@ extension AgentProTab { } return ("enabled", OpenClawBrand.accent) } + + func localizedSkillStatus(_ status: String) -> String { + switch status { + case "off": + String(localized: "off") + case "blocked": + String(localized: "blocked") + case "disabled": + String(localized: "disabled") + case "setup": + String(localized: "setup") + case "enabled": + String(localized: "enabled") + default: + status + } + } } diff --git a/apps/ios/Sources/Design/AgentProTab+Usage.swift b/apps/ios/Sources/Design/AgentProTab+Usage.swift index 36e826e2c230..5d36f46115ad 100644 --- a/apps/ios/Sources/Design/AgentProTab+Usage.swift +++ b/apps/ios/Sources/Design/AgentProTab+Usage.swift @@ -68,7 +68,7 @@ extension AgentProTab { VStack(alignment: .leading, spacing: 3) { Text(day.date) .font(OpenClawType.subheadSemiBold) - Text("\(Self.compactNumber(day.totalTokens ?? 0)) tokens") + Text(verbatim: Self.tokenCountText(day.totalTokens ?? 0)) .font(OpenClawType.caption) .foregroundStyle(.secondary) } @@ -80,4 +80,13 @@ extension AgentProTab { .padding(.vertical, 10) .padding(.horizontal, 14) } + + private static func tokenCountText(_ count: Int) -> String { + if count == 1 { + return String(localized: "1 token") + } + return String( + format: String(localized: "%@ tokens"), + Self.compactNumber(count)) + } } diff --git a/apps/ios/Sources/Design/AgentProTab.swift b/apps/ios/Sources/Design/AgentProTab.swift index c24993e7f6c9..c4db6eb6598b 100644 --- a/apps/ios/Sources/Design/AgentProTab.swift +++ b/apps/ios/Sources/Design/AgentProTab.swift @@ -54,11 +54,11 @@ struct AgentProTab: View { var title: String { switch self { - case .all: "All" - case .enabled: "Enabled" - case .off: "Off" - case .setup: "Setup" - case .blocked: "Blocked" + case .all: String(localized: "All") + case .enabled: String(localized: "Enabled") + case .off: String(localized: "Off") + case .setup: String(localized: "Setup") + case .blocked: String(localized: "Blocked") } } } @@ -74,9 +74,9 @@ struct AgentProTab: View { var title: String { switch self { - case .all: "All" - case .online: "Online" - case .ready: "Ready" + case .all: String(localized: "All") + case .online: String(localized: "Online") + case .ready: String(localized: "Ready") } } diff --git a/apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift b/apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift index d86f58ee7df6..ffa9a41e836c 100644 --- a/apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift +++ b/apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift @@ -16,7 +16,7 @@ struct AgentWorkspaceFilesScreen: View { if let headerLeadingAction { OpenClawAdaptiveHeaderRow( title: "Files", - subtitle: self.agentId, + subtitle: .verbatim(self.agentId), titleFont: OpenClawType.title3SemiBold, subtitleFont: OpenClawType.subheadMedium) { @@ -137,7 +137,10 @@ struct AgentWorkspaceDirectoryList: View { if self.loadingMore { ProgressView() } else { - Text("\(self.entries.count) of \(self.totalEntries)") + Text(verbatim: String( + format: String(localized: "%1$@ of %2$@"), + self.entries.count.formatted(), + self.totalEntries.formatted())) .font(OpenClawType.caption) .foregroundStyle(.secondary) } diff --git a/apps/ios/Sources/Design/ChatProTab.swift b/apps/ios/Sources/Design/ChatProTab.swift index 214b13d1c17d..625cd690fd44 100644 --- a/apps/ios/Sources/Design/ChatProTab.swift +++ b/apps/ios/Sources/Design/ChatProTab.swift @@ -433,10 +433,14 @@ struct ChatProTab: View { private var messagePlaceholder: String { if self.gatewayConnected { - return String(localized: "Message \(self.agentDisplayName)...") + return String( + format: String(localized: "Message %@..."), + self.agentDisplayName) } if self.canQueueOffline { - return String(localized: "Message \(self.agentDisplayName); sends when connected") + return String( + format: String(localized: "Message %@; sends when connected"), + self.agentDisplayName) } return String(localized: "Connect to a gateway") } diff --git a/apps/ios/Sources/Design/CommandCenterSupport.swift b/apps/ios/Sources/Design/CommandCenterSupport.swift index 6cb19f09349b..1272bb020df9 100644 --- a/apps/ios/Sources/Design/CommandCenterSupport.swift +++ b/apps/ios/Sources/Design/CommandCenterSupport.swift @@ -58,7 +58,7 @@ struct CommandSessionRow: View { .frame(width: 7, height: 7) .accessibilityHidden(true) } - Text(self.item.title) + Text(verbatim: self.item.title) .font(OpenClawType.subheadSemiBold) .lineLimit(1) .minimumScaleFactor(0.82) @@ -69,12 +69,12 @@ struct CommandSessionRow: View { .foregroundStyle(OpenClawBrand.accent) .accessibilityHidden(true) } - Text(self.item.trailing) + Text(verbatim: self.item.trailing) .font(OpenClawType.caption2Medium) .foregroundStyle(.secondary) } HStack(spacing: 8) { - Text(self.item.detail) + Text(verbatim: self.item.detail) .font(OpenClawType.caption) .foregroundStyle(.secondary) .lineLimit(1) @@ -98,7 +98,15 @@ struct CommandSessionRow: View { private var progressLabel: String { guard let progress = item.progress else { - return self.item.state + switch self.item.state { + case "offline": return String(localized: "offline") + case "off": return String(localized: "off") + case "idle": return String(localized: "idle") + case "open": return String(localized: "open") + case "default": return String(localized: "default") + case "recent": return String(localized: "recent") + default: return self.item.state + } } if self.item.state == "offline" || self.item.state == "off" || self.item.state == "idle" { return self.item.state @@ -151,13 +159,17 @@ struct CommandSessionActionsModifier: ViewModifier { self.deleteButton } else { self.actionButton( - self.session.pinned == true ? "Unpin" : "Pin", + self.session.pinned == true + ? LocalizedStringKey("Unpin") + : LocalizedStringKey("Pin"), systemImage: self.session.pinned == true ? "pin.slash" : "pin") { self.actions.togglePinned() } self.actionButton( - self.session.unread == true ? "Mark as Read" : "Mark as Unread", + self.session.unread == true + ? LocalizedStringKey("Mark as Read") + : LocalizedStringKey("Mark as Unread"), systemImage: self.session.unread == true ? "envelope.open" : "envelope.badge") { self.actions.toggleUnread() @@ -181,7 +193,9 @@ struct CommandSessionActionsModifier: ViewModifier { Button { self.commitEditor() } label: { - Text(self.editor == .rename ? "Save" : "Create") + Text(self.editor == .rename + ? LocalizedStringKey("Save") + : LocalizedStringKey("Create")) .font(OpenClawType.subheadSemiBold) } Button(role: .cancel) { @@ -250,15 +264,19 @@ struct CommandSessionActionsModifier: ViewModifier { } private var editorTitle: String { - self.editor == .newGroup ? "New Group" : "Rename Session" + self.editor == .newGroup + ? String(localized: "New Group") + : String(localized: "Rename Session") } private var editorPlaceholder: String { - self.editor == .newGroup ? "Group name" : "Session name" + self.editor == .newGroup + ? String(localized: "Group name") + : String(localized: "Session name") } private func actionButton( - _ title: String, + _ title: LocalizedStringKey, systemImage: String, action: @escaping () -> Void) -> some View { @@ -330,8 +348,8 @@ struct CommandViewMoreRow: View { struct CommandEmptyStateRow: View { let icon: String - let title: String - let detail: String + let title: OpenClawTextValue + let detail: OpenClawTextValue var body: some View { HStack(spacing: 10) { @@ -344,10 +362,10 @@ struct CommandEmptyStateRow: View { .fill(OpenClawBrand.ok.opacity(0.10)) } VStack(alignment: .leading, spacing: 2) { - Text(self.title) + self.title.text .font(OpenClawType.subheadSemiBold) .lineLimit(1) - Text(self.detail) + self.detail.text .font(OpenClawType.caption2Medium) .foregroundStyle(.secondary) .lineLimit(1) diff --git a/apps/ios/Sources/Design/CommandCenterTab.swift b/apps/ios/Sources/Design/CommandCenterTab.swift index b56d601c4aa0..8bab5ff8b000 100644 --- a/apps/ios/Sources/Design/CommandCenterTab.swift +++ b/apps/ios/Sources/Design/CommandCenterTab.swift @@ -120,8 +120,8 @@ struct CommandCenterTab: View { private var header: some View { OpenClawAdaptiveHeaderRow( - title: self.headerTitle, - subtitle: self.gatewaySubtitle, + title: .localized(self.headerTitle), + subtitle: .localized(self.gatewaySubtitle), titleFont: OpenClawType.title3SemiBold, subtitleFont: OpenClawType.caption, subtitleLineLimit: 1) @@ -308,13 +308,13 @@ struct CommandCenterTab: View { private var gatewayConnectionText: String { switch self.gatewayDisplayState { case .connected: - "Online" + String(localized: "Online") case .connecting: - "Connecting" + String(localized: "Connecting") case .error: - "Attention" + String(localized: "Attention") case .disconnected: - "Offline" + String(localized: "Offline") } } @@ -334,12 +334,12 @@ struct CommandCenterTab: View { private var gatewayAddressText: String { self.normalized(self.appModel.gatewayRemoteAddress) ?? self.normalized(self.appModel.gatewayServerName) - ?? "Unknown" + ?? String(localized: "Unknown") } private var gatewayAgentCountText: String { guard self.gatewayConnected else { return "—" } - return "\(self.appModel.gatewayAgents.count)" + return self.appModel.gatewayAgents.count.formatted() } private var defaultChatWorkItem: WorkItem { @@ -361,7 +361,7 @@ struct CommandCenterTab: View { private var defaultChatActivityText: String { let activityAt = self.defaultChatSessionEntry?.lastActivityAt ?? self.defaultChatSessionEntry?.updatedAt guard let activityAt, activityAt > 0 else { - return "No recent activity" + return String(localized: "No recent activity") } return Self.relativeTimeText(forMilliseconds: activityAt) } @@ -567,13 +567,13 @@ struct CommandCenterTab: View { let lowercased = trimmed.lowercased() guard !trimmed.isEmpty else { return nil } if lowercased.contains(":ios-") { - return "iOS chat" + return String(localized: "iOS chat") } if lowercased.hasPrefix("telegram:") { - return "Telegram chat" + return String(localized: "Telegram chat") } if lowercased.hasPrefix("user:+") { - return "Direct chat" + return String(localized: "Direct chat") } if lowercased.hasPrefix("cron:") { return Self.humanizedSessionKey(String(trimmed.dropFirst("cron:".count))) @@ -666,10 +666,16 @@ struct CommandCenterTab: View { private var gatewaySubtitle: String { if let server = normalized(appModel.gatewayServerName) { - return "\(self.appModel.activeAgentName) on \(server)" + return String( + format: String(localized: "%@ on %@"), + self.appModel.activeAgentName, + server) } if let address = normalized(appModel.gatewayRemoteAddress) { - return "\(self.appModel.activeAgentName) via \(address)" + return String( + format: String(localized: "%@ via %@"), + self.appModel.activeAgentName, + address) } return self.appModel.gatewayDisplayStatusText } @@ -745,7 +751,9 @@ struct CommandSessionsScreen: View { Button { self.commitGroupEditor() } label: { - Text(self.groupEditor == .create ? "Create" : "Save") + Text(self.groupEditor == .create + ? LocalizedStringKey("Create") + : LocalizedStringKey("Save")) .font(OpenClawType.subheadSemiBold) } Button(role: .cancel) { @@ -771,7 +779,10 @@ struct CommandSessionsScreen: View { .font(OpenClawType.subheadSemiBold) } } message: { group in - Text("Sessions in \u{201C}\(group)\u{201D} move back to Ungrouped.") + Text(verbatim: String( + format: String( + localized: "Sessions in \u{201C}%@\u{201D} move back to Ungrouped."), + group)) .font(OpenClawType.caption) } } @@ -797,7 +808,9 @@ struct CommandSessionsScreen: View { CommandPanel(padding: 0) { VStack(spacing: 0) { HStack(spacing: 8) { - Text(self.showArchived ? "Archived sessions" : "Recent sessions") + Text(self.showArchived + ? LocalizedStringKey("Archived sessions") + : LocalizedStringKey("Recent sessions")) .font(OpenClawType.subheadBold) Spacer(minLength: 8) if self.isLoading { @@ -821,17 +834,18 @@ struct CommandSessionsScreen: View { CommandEmptyStateRow( icon: "exclamationmark.triangle.fill", title: "Sessions unavailable", - detail: loadErrorText) + detail: .verbatim(loadErrorText)) .padding(.horizontal, 10) .padding(.bottom, 10) } else if self.visibleSessions.isEmpty { CommandEmptyStateRow( icon: self.appModel .isCommandSessionListAvailable ? "bubble.left.and.text.bubble.right.fill" : "wifi.slash", - title: self.emptyTitle, - detail: self.appModel - .isCommandSessionListAvailable ? self.emptyDetail : - "Connect to the gateway.") + title: .verbatim(self.emptyTitle), + detail: .verbatim(self.appModel + .isCommandSessionListAvailable + ? self.emptyDetail + : String(localized: "Connect to the gateway."))) .padding(.horizontal, 10) .padding(.bottom, 10) } else { @@ -857,13 +871,18 @@ struct CommandSessionsScreen: View { private var headerDetail: String { if self.isLoading, self.sessions.isEmpty { - return self.showArchived ? "Loading archived sessions" : "Loading recent sessions" + return self.showArchived + ? String(localized: "Loading archived sessions") + : String(localized: "Loading recent sessions") } let count = self.visibleSessions.count if count == 0 { return self.emptyTitle } - return "\(count) \(count == 1 ? "session" : "sessions")" + if count == 1 { + return String(localized: "1 session") + } + return String(format: String(localized: "%@ sessions"), count.formatted()) } private var visibleSessions: [OpenClawChatSessionEntry] { @@ -889,12 +908,18 @@ struct CommandSessionsScreen: View { } private var emptyTitle: String { - guard self.appModel.isCommandSessionListAvailable else { return "Gateway offline" } - return self.showArchived ? "No archived sessions" : "No recent sessions" + guard self.appModel.isCommandSessionListAvailable else { + return String(localized: "Gateway offline") + } + return self.showArchived + ? String(localized: "No archived sessions") + : String(localized: "No recent sessions") } private var emptyDetail: String { - self.showArchived ? "Archived sessions will appear here." : "Start a chat and it will appear here." + self.showArchived + ? String(localized: "Archived sessions will appear here.") + : String(localized: "Start a chat and it will appear here.") } private var refreshID: String { @@ -943,7 +968,9 @@ struct CommandSessionsScreen: View { } private var groupEditorTitle: String { - self.groupEditor == .create ? "New Group" : "Rename Group" + self.groupEditor == .create + ? String(localized: "New Group") + : String(localized: "Rename Group") } private var groupEditorBinding: Binding { diff --git a/apps/ios/Sources/Design/IPadActivityScreen.swift b/apps/ios/Sources/Design/IPadActivityScreen.swift index efd5ccba09f6..dd12abbd62a8 100644 --- a/apps/ios/Sources/Design/IPadActivityScreen.swift +++ b/apps/ios/Sources/Design/IPadActivityScreen.swift @@ -79,7 +79,8 @@ struct IPadActivityScreen: View { ProStatusRow( icon: "hand.raised.fill", title: "Approval needed", - detail: pendingExecApprovalPrompt.commandPreview ?? pendingExecApprovalPrompt.commandText, + detail: .verbatim( + pendingExecApprovalPrompt.commandPreview ?? pendingExecApprovalPrompt.commandText), value: "pending", color: OpenClawBrand.warn, actionTitle: nil, @@ -90,7 +91,7 @@ struct IPadActivityScreen: View { ProStatusRow( icon: self.gatewayConnected ? "network" : "wifi.slash", title: "Gateway", - detail: self.gatewayDetailText, + detail: .verbatim(self.gatewayDetailText), value: self.gatewayStateText.lowercased(), color: self.gatewayConnected ? OpenClawBrand.ok : .secondary, actionTitle: self.gatewayConnected ? nil : "Settings", @@ -101,7 +102,7 @@ struct IPadActivityScreen: View { ProStatusRow( icon: "square.and.arrow.down", title: "Share intake", - detail: self.appModel.lastShareEventText, + detail: .verbatim(self.appModel.lastShareEventText), value: "iPad", color: OpenClawBrand.accentForeground, actionTitle: nil, @@ -122,7 +123,7 @@ struct IPadActivityScreen: View { ProStatusRow( icon: "exclamationmark.triangle.fill", title: "Sessions unavailable", - detail: loadErrorText, + detail: .verbatim(loadErrorText), value: "error", color: OpenClawBrand.warn, actionTitle: nil, @@ -144,8 +145,8 @@ struct IPadActivityScreen: View { Divider().padding(.leading, 58) ProStatusRow( icon: row.icon, - title: row.title, - detail: row.detail, + title: .localized(row.title), + detail: .localized(row.detail), value: row.state, color: row.color, actionTitle: "Open", diff --git a/apps/ios/Sources/Design/IPadSidebarFeaturePreviews.swift b/apps/ios/Sources/Design/IPadSidebarFeaturePreviews.swift index f9cab0a3ce87..8baf0fa906c9 100644 --- a/apps/ios/Sources/Design/IPadSidebarFeaturePreviews.swift +++ b/apps/ios/Sources/Design/IPadSidebarFeaturePreviews.swift @@ -303,8 +303,8 @@ private struct IPadActivityStatesPreview: View { action: {}) ProStatusRow( icon: gatewayValue == "online" ? "network" : "wifi.slash", - title: gatewayTitle, - detail: gatewayDetail, + title: .localized(gatewayTitle), + detail: .localized(gatewayDetail), value: gatewayValue, color: gatewayColor, actionTitle: gatewayValue == "online" ? nil : "Settings", @@ -322,8 +322,8 @@ private struct IPadActivityStatesPreview: View { Divider().padding(.leading, 58) ProStatusRow( icon: row.icon, - title: row.title, - detail: row.detail, + title: .localized(row.title), + detail: .localized(row.detail), value: row.state, color: row.color, actionTitle: "Open", @@ -333,8 +333,8 @@ private struct IPadActivityStatesPreview: View { Divider().padding(.leading, 58) ProStatusRow( icon: row.icon, - title: row.title, - detail: row.detail, + title: .localized(row.title), + detail: .localized(row.detail), value: row.value, color: row.color, actionTitle: nil, diff --git a/apps/ios/Sources/Design/IPadSidebarScreenChrome.swift b/apps/ios/Sources/Design/IPadSidebarScreenChrome.swift index 28b460fcf707..821b188d66f9 100644 --- a/apps/ios/Sources/Design/IPadSidebarScreenChrome.swift +++ b/apps/ios/Sources/Design/IPadSidebarScreenChrome.swift @@ -32,8 +32,8 @@ struct IPadSidebarScreenChrome: View { VStack(alignment: .leading, spacing: self.isCompactHeight ? 10 : 16) { if !self.usesNativeNavigationChrome { OpenClawAdaptiveHeaderRow( - title: self.title, - subtitle: self.subtitle, + title: .localized(self.title), + subtitle: .localized(self.subtitle), titleFont: self.isCompactHeight ? OpenClawType.headline : OpenClawType.title2SemiBold, subtitleLineLimit: self.isCompactHeight ? 1 : 2) { diff --git a/apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift b/apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift index df07714ad6eb..73559979eba3 100644 --- a/apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift +++ b/apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift @@ -157,7 +157,11 @@ struct IPadSkillWorkshopScreen: View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline, spacing: 10) { VStack(alignment: .leading, spacing: 3) { - Text("\(self.filteredProposals.count) proposals") + Text(verbatim: self.filteredProposals.count == 1 + ? String(localized: "1 proposal") + : String( + format: String(localized: "%@ proposals"), + self.filteredProposals.count.formatted())) .font(OpenClawType.headline) Text(self.statusFilterLabel) .font(OpenClawType.caption) @@ -601,7 +605,7 @@ struct IPadSkillWorkshopScreen: View { let defaultID = Self.normalizedScopeID(self.appModel.gatewayDefaultAgentId) if let match = appModel.gatewayAgents.first(where: { Self.normalizedScopeID($0.id) == defaultID }) { let name = Self.normalizedScopeID(match.name) - return name.isEmpty ? "Default agent" : name + return name.isEmpty ? String(localized: "Default agent") : name } let activeName = Self.normalizedScopeID(self.appModel.activeAgentName) return activeName.isEmpty ? "Default agent" : activeName @@ -647,18 +651,18 @@ struct IPadSkillWorkshopScreen: View { static func proposalStatusFilterLabel(_ filter: String) -> String { switch filter { - case "pending": "Pending" - case "held": "Held" - case "applied": "Applied" - case "rejected": "Rejected" - default: "All" + case "pending": String(localized: "Pending") + case "held": String(localized: "Held") + case "applied": String(localized: "Applied") + case "rejected": String(localized: "Rejected") + default: String(localized: "All") } } static func proposalLaneLabel(_ status: String) -> String { switch status { - case "quarantined": "Quarantined" - case "stale": "Stale" + case "quarantined": String(localized: "Quarantined") + case "stale": String(localized: "Stale") case "pending", "applied", "rejected": self.proposalStatusFilterLabel(status) default: @@ -879,7 +883,9 @@ struct IPadSkillWorkshopScreen: View { agentId: self.selectedAgentParam, proposalId: proposal.id), timeoutSeconds: 30) - self.noticeText = action == .apply ? "Proposal applied." : "Proposal rejected." + self.noticeText = action == .apply + ? String(localized: "Proposal applied.") + : String(localized: "Proposal rejected.") await self.loadProposals(force: true) } catch { self.errorText = Self.message(for: error) @@ -928,15 +934,19 @@ struct IPadSkillProposalKanbanColumn: View { ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { VStack(spacing: 0) { ProPanelHeader( - title: IPadSkillWorkshopScreen.proposalLaneLabel(self.status), + title: .localized(IPadSkillWorkshopScreen.proposalLaneLabel(self.status)), value: "\(self.proposals.count)", actionTitle: nil, action: nil) if self.proposals.isEmpty { + let lane = IPadSkillWorkshopScreen.proposalLaneLabel(self.status) ProStatusRow( icon: "hammer", - title: "No \(IPadSkillWorkshopScreen.proposalLaneLabel(self.status).lowercased()) proposals", + title: .verbatim( + String( + format: String(localized: "No proposals in %@"), + lane)), detail: "Matching proposals appear here after gateway refresh.", value: "empty", color: .secondary, @@ -1213,11 +1223,15 @@ struct IPadSkillProposal: Identifiable { var ageLabel: String { let diff = max(0, Date().timeIntervalSince1970 * 1000 - self.updatedAtMs) let minutes = Int(diff / 60000) - if minutes < 1 { return "now" } - if minutes < 60 { return "\(minutes)m" } + if minutes < 1 { return String(localized: "now") } + if minutes < 60 { + return String(format: String(localized: "%@m"), minutes.formatted()) + } let hours = minutes / 60 - if hours < 24 { return "\(hours)h" } - return "\(hours / 24)d" + if hours < 24 { + return String(format: String(localized: "%@h"), hours.formatted()) + } + return String(format: String(localized: "%@d"), (hours / 24).formatted()) } var statusColor: Color { diff --git a/apps/ios/Sources/Design/IPadWorkboardScreen.swift b/apps/ios/Sources/Design/IPadWorkboardScreen.swift index 0fe17ed00115..5265531e7350 100644 --- a/apps/ios/Sources/Design/IPadWorkboardScreen.swift +++ b/apps/ios/Sources/Design/IPadWorkboardScreen.swift @@ -190,7 +190,11 @@ struct IPadWorkboardScreen: View { ProCard(radius: OpenClawProMetric.cardRadius) { VStack(alignment: .leading, spacing: 9) { HStack(alignment: .firstTextBaseline, spacing: 10) { - Text("\(self.filteredCards.count) cards") + Text(verbatim: self.filteredCards.count == 1 + ? String(localized: "1 card") + : String( + format: String(localized: "%@ cards"), + self.filteredCards.count.formatted())) .font(OpenClawType.headline) Spacer(minLength: 8) self.compactRefreshButton @@ -352,7 +356,10 @@ struct IPadWorkboardScreen: View { } .buttonStyle(.plain) .foregroundStyle(self.selectedStatus == status ? OpenClawBrand.accent : .primary) - .accessibilityLabel("Show \(IPadWorkboardDefaults.label(for: status)) cards") + .accessibilityLabel( + String( + format: String(localized: "Show %@ cards"), + IPadWorkboardDefaults.label(for: status))) } private var boardScopeMenu: some View { @@ -550,11 +557,14 @@ struct IPadWorkboardScreen: View { } } } label: { - Text(self.isCreatingCard ? "Creating..." : "Create") + Text(self.isCreatingCard + ? LocalizedStringKey("Creating...") + : LocalizedStringKey("Create")) .font(OpenClawType.subheadSemiBold) } .disabled(self.isCreatingCard) - .accessibilityHint(self.createUnavailableMessage ?? "Creates a workboard card") + .accessibilityHint( + self.createUnavailableMessage ?? String(localized: "Creates a workboard card")) } } } @@ -602,13 +612,13 @@ struct IPadWorkboardScreen: View { private var createUnavailableMessage: String? { if self.isCreatingCard { - return "Card creation is already in progress." + return String(localized: "Card creation is already in progress.") } if !self.canWrite { return Self.compactWriteUnavailableMessage(canRead: self.canRead) } if self.trimmedDraftTitle.isEmpty { - return "Enter a title to create a card." + return String(localized: "Enter a title to create a card.") } return nil } @@ -627,11 +637,16 @@ struct IPadWorkboardScreen: View { } static func workboardSubtitle(boardScopeLabel: String, selectedStatus: String) -> String { - "\(boardScopeLabel) / \(IPadWorkboardDefaults.label(for: selectedStatus))" + String( + format: String(localized: "%@ / %@"), + boardScopeLabel, + IPadWorkboardDefaults.label(for: selectedStatus)) } static func compactWriteUnavailableMessage(canRead: Bool) -> String { - canRead ? "Read-only gateway." : "Connect from Settings to create, move, and dispatch cards." + canRead + ? String(localized: "Read-only gateway.") + : String(localized: "Connect from Settings to create, move, and dispatch cards.") } static func boardScopeOptions(knownBoardIDs: [String], cardBoardIDs: [String]) -> [String] { @@ -957,15 +972,19 @@ struct IPadWorkboardKanbanColumn: View { ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) { VStack(spacing: 0) { ProPanelHeader( - title: IPadWorkboardDefaults.label(for: self.status), + title: .localized(IPadWorkboardDefaults.label(for: self.status)), value: "\(self.cards.count)", actionTitle: nil, action: nil) if self.cards.isEmpty { + let lane = IPadWorkboardDefaults.label(for: self.status) ProStatusRow( icon: "tray", - title: "No \(IPadWorkboardDefaults.label(for: self.status).lowercased()) cards", + title: .verbatim( + String( + format: String(localized: "No cards in %@"), + lane)), detail: "Cards moved into this lane appear here.", value: "empty", color: .secondary, @@ -1051,12 +1070,16 @@ private struct IPadWorkboardKanbanCard: View { Button { self.move(status) } label: { - Text("Move to \(IPadWorkboardDefaults.label(for: status))") + Text(verbatim: String( + format: String(localized: "Move to %@"), + IPadWorkboardDefaults.label(for: status))) .font(OpenClawType.subheadSemiBold) } } Button(action: self.archive) { - Text(self.card.metadata?.archivedAt == nil ? "Archive" : "Unarchive") + Text(self.card.metadata?.archivedAt == nil + ? LocalizedStringKey("Archive") + : LocalizedStringKey("Unarchive")) .font(OpenClawType.subheadSemiBold) } } label: { @@ -1183,7 +1206,9 @@ struct IPadWorkboardQueueRow: View { .tint(OpenClawBrand.accentHot) } Button(action: self.archive) { - Text(self.card.metadata?.archivedAt == nil ? "Archive" : "Unarchive") + Text(self.card.metadata?.archivedAt == nil + ? LocalizedStringKey("Archive") + : LocalizedStringKey("Unarchive")) .font(OpenClawType.subheadSemiBold) } .tint(.secondary) @@ -1206,12 +1231,16 @@ struct IPadWorkboardQueueRow: View { Button { self.move(status) } label: { - Text("Move to \(IPadWorkboardDefaults.label(for: status))") + Text(verbatim: String( + format: String(localized: "Move to %@"), + IPadWorkboardDefaults.label(for: status))) .font(OpenClawType.subheadSemiBold) } } Button(action: self.archive) { - Text(self.card.metadata?.archivedAt == nil ? "Archive" : "Unarchive") + Text(self.card.metadata?.archivedAt == nil + ? LocalizedStringKey("Archive") + : LocalizedStringKey("Unarchive")) .font(OpenClawType.subheadSemiBold) } } @@ -1309,7 +1338,9 @@ private struct IPadWorkboardCardDetailSheet: View { Button { self.archive() } label: { - Text(self.card.metadata?.archivedAt == nil ? "Archive" : "Unarchive") + Text(self.card.metadata?.archivedAt == nil + ? LocalizedStringKey("Archive") + : LocalizedStringKey("Unarchive")) .font(OpenClawType.subheadSemiBold) } .disabled(!self.canWrite || self.isBusy) @@ -1363,11 +1394,21 @@ private enum IPadWorkboardDefaults { static let statuses = ["todo", "scheduled", "ready", "running", "review", "blocked", "done"] static func label(for status: String) -> String { - status - .replacingOccurrences(of: "_", with: " ") - .split(separator: " ") - .map { $0.prefix(1).uppercased() + $0.dropFirst() } - .joined(separator: " ") + switch status { + case "todo": String(localized: "Todo") + case "scheduled": String(localized: "Scheduled") + case "ready": String(localized: "Ready") + case "running": String(localized: "Running") + case "review": String(localized: "Review") + case "blocked": String(localized: "Blocked") + case "done": String(localized: "Done") + default: + status + .replacingOccurrences(of: "_", with: " ") + .split(separator: " ") + .map { $0.prefix(1).uppercased() + $0.dropFirst() } + .joined(separator: " ") + } } static func rank(_ status: String) -> Int { @@ -1478,20 +1519,25 @@ struct IPadWorkboardDispatchSummary: Decodable { startedCount + self.promotedCount + self.reclaimedCount + self.orchestratedCount + self.blockedCount + self.startFailureCount) if total == 0, self.startFailureCount == 0, self.blockedCount == 0 { - return "No cards dispatched." + return String(localized: "No cards dispatched.") } let outcomes = [ - Self.outcomeText(self.startedCount, "started"), - Self.outcomeText(self.promotedCount, "promoted"), - Self.outcomeText(self.reclaimedCount, "reclaimed"), - Self.outcomeText(self.orchestratedCount, "orchestrated"), - Self.outcomeText(self.blockedCount, "blocked"), - Self.outcomeText(self.startFailureCount, "failed"), + Self.outcomeText(self.startedCount, .started), + Self.outcomeText(self.promotedCount, .promoted), + Self.outcomeText(self.reclaimedCount, .reclaimed), + Self.outcomeText(self.orchestratedCount, .orchestrated), + Self.outcomeText(self.blockedCount, .blocked), + Self.outcomeText(self.startFailureCount, .failed), ].compactMap(\.self) guard !outcomes.isEmpty else { - return "\(total) dispatched." + return String( + format: String(localized: "%@ dispatched."), + total.formatted()) } - return "\(total) dispatched: \(outcomes.joined(separator: ", "))." + return String( + format: String(localized: "%@ dispatched: %@."), + total.formatted(), + outcomes.joined(separator: ", ")) } private static func arrayCount( @@ -1501,9 +1547,26 @@ struct IPadWorkboardDispatchSummary: Decodable { (try? container.decode([IPadWorkboardDispatchEntry].self, forKey: key).count) ?? 0 } - private static func outcomeText(_ count: Int, _ label: String) -> String? { + private enum Outcome { + case started + case promoted + case reclaimed + case orchestrated + case blocked + case failed + } + + private static func outcomeText(_ count: Int, _ outcome: Outcome) -> String? { guard count > 0 else { return nil } - return "\(count) \(label)" + let format = switch outcome { + case .started: String(localized: "%@ started") + case .promoted: String(localized: "%@ promoted") + case .reclaimed: String(localized: "%@ reclaimed") + case .orchestrated: String(localized: "%@ orchestrated") + case .blocked: String(localized: "%@ blocked") + case .failed: String(localized: "%@ failed") + } + return String(format: format, count.formatted()) } } diff --git a/apps/ios/Sources/Design/OpenClawProComponents.swift b/apps/ios/Sources/Design/OpenClawProComponents.swift index 973b6478670b..0b0159f06fab 100644 --- a/apps/ios/Sources/Design/OpenClawProComponents.swift +++ b/apps/ios/Sources/Design/OpenClawProComponents.swift @@ -23,6 +23,28 @@ enum OpenClawRadius { static let md: CGFloat = 12 } +enum OpenClawTextValue: 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 OpenClawProBackground: View { var body: some View { Color(uiColor: .systemGroupedBackground) @@ -31,14 +53,14 @@ struct OpenClawProBackground: View { } struct ProSectionHeader: View { - let title: String - var actionTitle: String? + let title: OpenClawTextValue + var actionTitle: OpenClawTextValue? var action: (() -> Void)? var uppercase = true var body: some View { HStack { - Text(self.title) + self.title.text .font(OpenClawType.footnoteMedium) .foregroundStyle(.secondary) .textCase(self.uppercase ? .uppercase : nil) @@ -46,12 +68,12 @@ struct ProSectionHeader: View { if let actionTitle { if let action { Button(action: action) { - Text(actionTitle) + actionTitle.text .font(OpenClawType.footnoteMedium) } .foregroundStyle(OpenClawBrand.accent) } else { - Text(actionTitle) + actionTitle.text .font(OpenClawType.footnoteMedium) .foregroundStyle(.secondary) } @@ -326,14 +348,14 @@ enum OpenClawNoticeDetail { struct OpenClawNoticeBanner: View { let icon: String - let title: String - let message: String - let ownerLabel: String + let title: OpenClawTextValue + let message: OpenClawTextValue + let ownerLabel: OpenClawTextValue let tint: Color var detail: OpenClawNoticeDetail? - var primaryActionTitle: String? + var primaryActionTitle: OpenClawTextValue? var onPrimaryAction: (() -> Void)? - var secondaryActionTitle: String? + var secondaryActionTitle: OpenClawTextValue? var onSecondaryAction: (() -> Void)? var body: some View { @@ -344,16 +366,16 @@ struct OpenClawNoticeBanner: View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(self.title) + self.title.text .font(OpenClawType.subheadSemiBold) .multilineTextAlignment(.leading) Spacer(minLength: 0) - Text(self.ownerLabel) + self.ownerLabel.text .font(OpenClawType.captionSemiBold) .foregroundStyle(.secondary) } - Text(self.message) + self.message.text .font(OpenClawType.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -367,7 +389,7 @@ struct OpenClawNoticeBanner: View { HStack(spacing: 10) { if let primaryActionTitle, let onPrimaryAction { Button(action: onPrimaryAction) { - Text(primaryActionTitle) + primaryActionTitle.text .font(OpenClawType.captionSemiBold) } .font(OpenClawType.captionSemiBold) @@ -376,7 +398,7 @@ struct OpenClawNoticeBanner: View { } if let secondaryActionTitle, let onSecondaryAction { Button(action: onSecondaryAction) { - Text(secondaryActionTitle) + secondaryActionTitle.text .font(OpenClawType.captionSemiBold) } .font(OpenClawType.captionSemiBold) @@ -400,7 +422,9 @@ struct OpenClawNoticeBanner: View { .foregroundStyle(self.tint) .fixedSize(horizontal: false, vertical: true) case let .requestID(value): - Text("Request ID: \(value)") + Text(verbatim: String( + format: String(localized: "Request ID: %@"), + value)) .font(OpenClawType.monoSmallMedium) .foregroundStyle(.secondary) .textSelection(.enabled) @@ -410,8 +434,8 @@ struct OpenClawNoticeBanner: View { } struct OpenClawAdaptiveHeaderRow: View { - let title: String - let subtitle: String? + let title: OpenClawTextValue + let subtitle: OpenClawTextValue? var titleFont: Font = OpenClawType.title3SemiBold var subtitleFont: Font = OpenClawType.subhead var subtitleLineLimit: Int? = 2 @@ -419,8 +443,8 @@ struct OpenClawAdaptiveHeaderRow: View { @ViewBuilder let accessory: Accessory init( - title: String, - subtitle: String? = nil, + title: OpenClawTextValue, + subtitle: OpenClawTextValue? = nil, titleFont: Font = OpenClawType.title3SemiBold, subtitleFont: Font = OpenClawType.subhead, subtitleLineLimit: Int? = 2, @@ -478,13 +502,13 @@ struct OpenClawAdaptiveHeaderRow: View { private var titleBlock: some View { VStack(alignment: .leading, spacing: 4) { - Text(self.title) + self.title.text .font(self.titleFont) .lineLimit(2) .minimumScaleFactor(0.86) .fixedSize(horizontal: false, vertical: true) - if let subtitle, !subtitle.isEmpty { - Text(subtitle) + if let subtitle { + subtitle.text .font(self.subtitleFont) .foregroundStyle(.secondary) .lineLimit(self.subtitleLineLimit) @@ -538,7 +562,7 @@ enum OpenClawStatusTone { struct OpenClawStatusBadge: View { @Environment(\.colorScheme) private var colorScheme - let label: String + let label: OpenClawTextValue let tone: OpenClawStatusTone var body: some View { @@ -547,7 +571,7 @@ struct OpenClawStatusBadge: View { .fill(self.tone.color) .frame(width: 7, height: 7) .shadow(color: self.tone.color.opacity(0.55), radius: 3) - Text(self.label) + self.label.text .font(OpenClawType.caption2SemiBold) .foregroundStyle(self.tone.color) } @@ -626,8 +650,11 @@ struct OpenClawGatewayCompactPill: View { @Environment(NodeAppModel.self) private var appModel var body: some View { - OpenClawStatusBadge(label: self.title, tone: self.tone) - .accessibilityLabel("Gateway \(self.title)") + OpenClawStatusBadge(label: .localized(self.title), tone: self.tone) + .accessibilityLabel( + String( + format: String(localized: "Gateway %@"), + self.title)) } private var title: String { @@ -659,7 +686,7 @@ struct OpenClawGatewayCompactPill: View { struct ProMetricTile: View { @Environment(\.colorScheme) private var colorScheme - let title: String + let title: OpenClawTextValue let value: String let icon: String let color: Color @@ -680,7 +707,7 @@ struct ProMetricTile: View { .font(OpenClawType.headlineBold) .lineLimit(1) .minimumScaleFactor(0.72) - Text(self.title) + self.title.text .font(OpenClawType.caption2Medium) .foregroundStyle(.secondary) .lineLimit(1) @@ -695,7 +722,7 @@ struct ProMetricTile: View { struct ProMetric: Identifiable { let id = UUID() let icon: String - let title: String + let title: OpenClawTextValue let value: String let color: Color } @@ -727,9 +754,9 @@ struct ProMetricGrid: View { } struct ProPanelHeader: View { - let title: String + let title: OpenClawTextValue var value: String? - var actionTitle: String? + var actionTitle: OpenClawTextValue? var actionIcon: String? var actionAccessibilityLabel: String? var isActionDisabled = false @@ -737,7 +764,7 @@ struct ProPanelHeader: View { var body: some View { HStack(spacing: 8) { - Text(self.title) + self.title.text .font(OpenClawType.subheadSemiBold) if let value { Text(value) @@ -759,11 +786,14 @@ struct ProPanelHeader: View { Button(action: action) { Image(systemName: actionIcon) } - .accessibilityLabel(self.actionAccessibilityLabel ?? actionTitle ?? self.title) + .accessibilityLabel( + self.actionAccessibilityLabel.map { Text(LocalizedStringKey($0)) } + ?? actionTitle?.text + ?? self.title.text) .disabled(self.isActionDisabled) } else if let actionTitle { Button(action: action) { - Text(actionTitle) + actionTitle.text .font(OpenClawType.captionSemiBold) } .disabled(self.isActionDisabled) @@ -774,21 +804,21 @@ struct ProPanelHeader: View { struct ProStatusRow: View { let icon: String - let title: String - let detail: String + let title: OpenClawTextValue + let detail: OpenClawTextValue let value: String? let color: Color - var actionTitle: String? + var actionTitle: OpenClawTextValue? var action: (() -> Void)? var body: some View { HStack(alignment: .top, spacing: 12) { ProIconBadge(systemName: self.icon, color: self.color) VStack(alignment: .leading, spacing: 4) { - Text(self.title) + self.title.text .font(OpenClawType.subheadSemiBold) .lineLimit(1) - Text(self.detail) + self.detail.text .font(OpenClawType.caption) .foregroundStyle(.secondary) .lineLimit(2) @@ -800,7 +830,7 @@ struct ProStatusRow: View { } if let actionTitle, let action { Button(action: action) { - Text(actionTitle) + actionTitle.text .font(OpenClawType.captionSemiBold) } .buttonStyle(.bordered) diff --git a/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift b/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift index db5bc6e74bc3..add875987920 100644 --- a/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift +++ b/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift @@ -303,10 +303,17 @@ struct RootTabsPhoneControlHub: View { private var gatewayAccessibilityLabel: Text { if let gatewayDisplayLabel { - Text("Gateway \(self.gatewayStateText), \(gatewayDisplayLabel), \(self.sidebarActiveAgentTitle)") + Text(verbatim: String( + format: String(localized: "Gateway %1$@, %2$@, %3$@"), + self.gatewayStateText, + gatewayDisplayLabel, + self.sidebarActiveAgentTitle)) .font(OpenClawType.captionMedium) } else { - Text("Gateway \(self.gatewayStateText), \(self.sidebarActiveAgentTitle)") + Text(verbatim: String( + format: String(localized: "Gateway %1$@, %2$@"), + self.gatewayStateText, + self.sidebarActiveAgentTitle)) .font(OpenClawType.captionMedium) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift index 3d0e507cb02d..26e86961c01b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift @@ -230,7 +230,10 @@ struct OpenClawChatComposer: View { } .frame(width: 14, height: 14) .accessibilityElement(children: .ignore) - .accessibilityLabel("Context \(percentage)% used") + .accessibilityLabel( + String( + format: String(localized: "Context %@%% used"), + percentage.formatted())) } private var thinkingPicker: some View { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatContextUsage.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatContextUsage.swift index 6db8c5675a1e..2376b78f819b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatContextUsage.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatContextUsage.swift @@ -119,24 +119,34 @@ struct ChatMessageUsagePresentation: Equatable { if let input { visualParts.append("↑\(self.tokens(input))") - accessibilityParts.append(String(localized: "Input tokens: \(input)")) + accessibilityParts.append(String( + format: String(localized: "Input tokens: %@"), + input.formatted())) } if let output { visualParts.append("↓\(self.tokens(output))") - accessibilityParts.append(String(localized: "Output tokens: \(output)")) + accessibilityParts.append(String( + format: String(localized: "Output tokens: %@"), + output.formatted())) } if let cacheRead { visualParts.append("R\(self.tokens(cacheRead))") - accessibilityParts.append(String(localized: "Cache read tokens: \(cacheRead)")) + accessibilityParts.append(String( + format: String(localized: "Cache read tokens: %@"), + cacheRead.formatted())) } if let cacheWrite { visualParts.append("W\(self.tokens(cacheWrite))") - accessibilityParts.append(String(localized: "Cache write tokens: \(cacheWrite)")) + accessibilityParts.append(String( + format: String(localized: "Cache write tokens: %@"), + cacheWrite.formatted())) } if let cost = usage.cost?.total, cost > 0 { let formattedCost = String(format: "$%.4f", locale: Locale(identifier: "en_US_POSIX"), cost) visualParts.append(formattedCost) - accessibilityParts.append(String(localized: "Cost: \(formattedCost)")) + accessibilityParts.append(String( + format: String(localized: "Cost: %@"), + formattedCost)) } // Context pressure mirrors the Control UI prompt size. Output is response data; @@ -155,11 +165,17 @@ struct ChatMessageUsagePresentation: Equatable { visualParts.append("\(warningPrefix)\(contextPercent)% \(String(localized: "ctx"))") switch pressure { case .normal: - accessibilityParts.append(String(localized: "\(contextPercent) percent of context used")) + accessibilityParts.append(String( + format: String(localized: "%@ percent of context used"), + contextPercent.formatted())) case .warning: - accessibilityParts.append(String(localized: "Warning: \(contextPercent) percent of context used")) + accessibilityParts.append(String( + format: String(localized: "Warning: %@ percent of context used"), + contextPercent.formatted())) case .danger: - accessibilityParts.append(String(localized: "Critical: \(contextPercent) percent of context used")) + accessibilityParts.append(String( + format: String(localized: "Critical: %@ percent of context used"), + contextPercent.formatted())) } } @@ -221,7 +237,7 @@ struct ChatContextUsageIndicator: View { .frame(width: 13, height: 13) if let percent = self.usage.percentUsed { - Text("\(percent)%") + Text(verbatim: "\(percent.formatted())%") .font(OpenClawChatTypography.captionSemiBold) .monospacedDigit() .foregroundStyle(.secondary) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatLinkPreview.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatLinkPreview.swift index f826d604e7e4..cdeb585e0b0d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatLinkPreview.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatLinkPreview.swift @@ -825,7 +825,9 @@ struct ChatLinkPreview: View { self.model.expanded = true } label: { HStack(spacing: 6) { - Text("Preview · \(self.domain)") + Text(verbatim: String( + format: String(localized: "Preview · %@"), + self.domain)) .font(OpenClawChatTypography.captionSemiBold) .foregroundStyle(OpenClawChatTheme.assistantText.opacity(0.65)) .lineLimit(1) @@ -843,7 +845,10 @@ struct ChatLinkPreview: View { .strokeBorder(OpenClawChatTheme.divider, lineWidth: 1)) } .buttonStyle(.plain) - .accessibilityLabel("Expand link preview for \(self.domain)") + .accessibilityLabel( + String( + format: String(localized: "Expand link preview for %@"), + self.domain)) } private var expandedCard: some View { @@ -898,7 +903,10 @@ struct ChatLinkPreview: View { .strokeBorder(OpenClawChatTheme.divider, lineWidth: 1)) } .buttonStyle(.plain) - .accessibilityLabel("Open \(self.domain)") + .accessibilityLabel( + String( + format: String(localized: "Open %@"), + self.domain)) } private var domain: String { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift index 3144d05e8bb1..2a0fda598f4c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift @@ -34,7 +34,11 @@ struct ChatAgentAvatar: View { Circle() .strokeBorder(Color.white.opacity(0.18), lineWidth: 1)) .shadow(color: (self.tint ?? OpenClawChatTheme.accent).opacity(0.18), radius: 8, y: 4) - .accessibilityLabel(self.name.map { "\($0) avatar" } ?? "Agent avatar") + .accessibilityLabel(self.name.map { + String( + format: String(localized: "%@ avatar"), + $0) + } ?? String(localized: "Agent avatar")) } private var displayText: String { @@ -856,7 +860,7 @@ struct ChatPendingToolsBubble: View { let display = ToolDisplayRegistry.resolve(name: call.name, args: call.args) VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 8) { - Text("\(display.emoji) \(display.label)") + Text(verbatim: "\(display.emoji) \(display.label)") .font(OpenClawChatTypography.mono(size: 13, relativeTo: .footnote)) .lineLimit(1) Spacer(minLength: 0) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift index 9284bbb0ae93..fd84f19c0a9f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift @@ -23,7 +23,9 @@ extension OpenClawChatViewModel { try Data(contentsOf: fileURL) }.value } catch { - self.errorText = String(localized: "Could not attach voice note: \(error.localizedDescription)") + self.errorText = String( + format: String(localized: "Could not attach voice note: %@"), + error.localizedDescription) return } @@ -74,7 +76,7 @@ extension OpenClawChatViewModel { return UTType(mimeType: mimeType) ?? .data }() guard uti.conforms(to: .image) else { - self.errorText = "Only image attachments are supported right now" + self.errorText = String(localized: "Only image attachments are supported right now") return } @@ -84,12 +86,17 @@ extension OpenClawChatViewModel { try ChatImageProcessor.processForUpload(data: data) }.value } catch { - self.errorText = "Could not process \(fileName): \(error.localizedDescription)" + self.errorText = String( + format: String(localized: "Could not process %1$@: %2$@"), + fileName, + error.localizedDescription) return } if processed.count > Self.maxAttachmentBytes { - self.errorText = "Attachment \(fileName) exceeds 5 MB limit after resizing" + self.errorText = String( + format: String(localized: "Attachment %@ exceeds 5 MB limit after resizing"), + fileName) return } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWindowShell.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWindowShell.swift index ed4a195526a9..f76d10879c7c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWindowShell.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatWindowShell.swift @@ -47,7 +47,11 @@ public struct OpenClawChatWindowShell: View { .font(OpenClawChatTypography.body) } } message: { - Text("This resets the conversation for \(self.activeSessionTitle). The session key stays the same.") + Text(verbatim: String( + format: String(localized: """ + This resets the conversation for %@. The session key stays the same. + """), + self.activeSessionTitle)) .font(OpenClawChatTypography.body) } .onChange(of: self.viewModel.pendingRunCount) { previous, current in @@ -286,7 +290,9 @@ private struct ChatContextUsageMenu: View { Text(self.tokensLine) .font(OpenClawChatTypography.body(size: 13, weight: .regular, relativeTo: .body)) if let cost = self.usage.totalCost { - Text("Session cost \(ChatContextUsageFormatter.cost(cost))") + Text(verbatim: String( + format: String(localized: "Session cost %@"), + ChatContextUsageFormatter.cost(cost))) .font(OpenClawChatTypography.body(size: 13, weight: .regular, relativeTo: .body)) } Divider() diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift index 7495478ba735..82c25cf7f8bc 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift @@ -173,7 +173,9 @@ public final class OpenClawVoiceNoteRecorder { try? FileManager.default.removeItem(at: fileURL) self.capture.cancel() self.onRecordingActiveChanged?(false) - self.fail(message: String(localized: "Could not start recording: \(error.localizedDescription)")) + self.fail(message: String( + format: String(localized: "Could not start recording: %@"), + error.localizedDescription)) return false }