From cc85d8256f4b8498b2996514904d256be4e5fce6 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:37:15 +0200 Subject: [PATCH] fix(ios): use plural-aware localized counts --- .../Design/AgentProDreamingDestination.swift | 11 +++++------ .../Design/AgentProNodesDestination.swift | 16 ++++++---------- .../Sources/Design/AgentProTab+Overview.swift | 6 ++---- .../ios/Sources/Design/AgentProTab+Usage.swift | 7 +------ apps/ios/Sources/Design/CommandCenterTab.swift | 6 ++---- .../Design/IPadSkillWorkshopScreen.swift | 11 +++++------ .../Sources/Design/IPadWorkboardScreen.swift | 11 +++++------ .../Gateway/ExecApprovalPromptDialog.swift | 18 ++++++++---------- apps/ios/WatchApp/Sources/WatchInboxView.swift | 18 ++++++------------ scripts/apple-app-i18n.ts | 6 +++--- test/scripts/apple-app-i18n.test.ts | 4 ++-- 11 files changed, 45 insertions(+), 69 deletions(-) diff --git a/apps/ios/Sources/Design/AgentProDreamingDestination.swift b/apps/ios/Sources/Design/AgentProDreamingDestination.swift index f34dcafce937..2ae23fe30d9a 100644 --- a/apps/ios/Sources/Design/AgentProDreamingDestination.swift +++ b/apps/ios/Sources/Design/AgentProDreamingDestination.swift @@ -305,17 +305,16 @@ struct AgentProDreamingDestination: View { } private func dreamDiaryDayView(_ day: DreamDiaryDay) -> some View { - VStack(alignment: .leading, spacing: 8) { + let count = day.entryCount + let entryCountText = String( + AttributedString(localized: "^[\(count) entry](inflect: true)").characters) + return VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline) { Text(day.title) .font(OpenClawType.subheadSemiBold) .lineLimit(1) Spacer(minLength: 8) - Text(verbatim: day.entryCount == 1 - ? String(localized: "1 entry") - : String( - format: String(localized: "%@ entries"), - day.entryCount.formatted())) + Text(verbatim: entryCountText) .font(OpenClawType.caption2SemiBold) .foregroundStyle(OpenClawBrand.accent) } diff --git a/apps/ios/Sources/Design/AgentProNodesDestination.swift b/apps/ios/Sources/Design/AgentProNodesDestination.swift index 7fb76625c32f..79ced1ab8bf5 100644 --- a/apps/ios/Sources/Design/AgentProNodesDestination.swift +++ b/apps/ios/Sources/Design/AgentProNodesDestination.swift @@ -334,21 +334,17 @@ struct AgentProNodesDestination: View { let tags = (entry.tags ?? []).prefix(2).joined(separator: ", ") let scopesCount = entry.scopes?.count ?? 0 let rolesCount = entry.roles?.count ?? 0 + let scopesText = String( + AttributedString(localized: "^[\(scopesCount) scope](inflect: true)").characters) + let rolesText = String( + AttributedString(localized: "^[\(rolesCount) role](inflect: true)").characters) let labels = [ Self.normalized(entry.instanceid).map { String(format: String(localized: "instance %@"), $0) }, tags.isEmpty ? nil : tags, - scopesCount > 0 - ? String( - format: String(localized: "%@ scopes"), - scopesCount.formatted()) - : nil, - rolesCount > 0 - ? String( - format: String(localized: "%@ roles"), - rolesCount.formatted()) - : nil, + scopesCount > 0 ? scopesText : nil, + rolesCount > 0 ? rolesText : nil, ].compactMap(\.self) return labels.isEmpty ? nil : labels.joined(separator: " • ") } diff --git a/apps/ios/Sources/Design/AgentProTab+Overview.swift b/apps/ios/Sources/Design/AgentProTab+Overview.swift index f0a87e764210..b7091557ba2d 100644 --- a/apps/ios/Sources/Design/AgentProTab+Overview.swift +++ b/apps/ios/Sources/Design/AgentProTab+Overview.swift @@ -644,10 +644,8 @@ extension AgentProTab { 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()) + return String( + AttributedString(localized: "^[\(count) agent](inflect: true) total").characters) } var instancesColor: Color { diff --git a/apps/ios/Sources/Design/AgentProTab+Usage.swift b/apps/ios/Sources/Design/AgentProTab+Usage.swift index 5d36f46115ad..fb69cb5d6b26 100644 --- a/apps/ios/Sources/Design/AgentProTab+Usage.swift +++ b/apps/ios/Sources/Design/AgentProTab+Usage.swift @@ -82,11 +82,6 @@ extension AgentProTab { } private static func tokenCountText(_ count: Int) -> String { - if count == 1 { - return String(localized: "1 token") - } - return String( - format: String(localized: "%@ tokens"), - Self.compactNumber(count)) + String(AttributedString(localized: "^[\(count) token](inflect: true)").characters) } } diff --git a/apps/ios/Sources/Design/CommandCenterTab.swift b/apps/ios/Sources/Design/CommandCenterTab.swift index 8bab5ff8b000..55750357ebef 100644 --- a/apps/ios/Sources/Design/CommandCenterTab.swift +++ b/apps/ios/Sources/Design/CommandCenterTab.swift @@ -879,10 +879,8 @@ struct CommandSessionsScreen: View { if count == 0 { return self.emptyTitle } - if count == 1 { - return String(localized: "1 session") - } - return String(format: String(localized: "%@ sessions"), count.formatted()) + return String( + AttributedString(localized: "^[\(count) session](inflect: true)").characters) } private var visibleSessions: [OpenClawChatSessionEntry] { diff --git a/apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift b/apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift index 73559979eba3..e67999505247 100644 --- a/apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift +++ b/apps/ios/Sources/Design/IPadSkillWorkshopScreen.swift @@ -153,15 +153,14 @@ struct IPadSkillWorkshopScreen: View { } private var compactFiltersCard: some View { - ProCard(radius: OpenClawProMetric.cardRadius) { + let count = self.filteredProposals.count + let countText = String( + AttributedString(localized: "^[\(count) proposal](inflect: true)").characters) + return ProCard(radius: OpenClawProMetric.cardRadius) { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline, spacing: 10) { VStack(alignment: .leading, spacing: 3) { - Text(verbatim: self.filteredProposals.count == 1 - ? String(localized: "1 proposal") - : String( - format: String(localized: "%@ proposals"), - self.filteredProposals.count.formatted())) + Text(verbatim: countText) .font(OpenClawType.headline) Text(self.statusFilterLabel) .font(OpenClawType.caption) diff --git a/apps/ios/Sources/Design/IPadWorkboardScreen.swift b/apps/ios/Sources/Design/IPadWorkboardScreen.swift index 5265531e7350..c75de0572257 100644 --- a/apps/ios/Sources/Design/IPadWorkboardScreen.swift +++ b/apps/ios/Sources/Design/IPadWorkboardScreen.swift @@ -187,14 +187,13 @@ struct IPadWorkboardScreen: View { } private var compactQueueControls: some View { - ProCard(radius: OpenClawProMetric.cardRadius) { + let count = self.filteredCards.count + let countText = String( + AttributedString(localized: "^[\(count) card](inflect: true)").characters) + return ProCard(radius: OpenClawProMetric.cardRadius) { VStack(alignment: .leading, spacing: 9) { HStack(alignment: .firstTextBaseline, spacing: 10) { - Text(verbatim: self.filteredCards.count == 1 - ? String(localized: "1 card") - : String( - format: String(localized: "%@ cards"), - self.filteredCards.count.formatted())) + Text(verbatim: countText) .font(OpenClawType.headline) Spacer(minLength: 8) self.compactRefreshButton diff --git a/apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift b/apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift index 6b44e0be68c9..6b2b6b606c2f 100644 --- a/apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift +++ b/apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift @@ -282,18 +282,16 @@ private struct ExecApprovalPromptCard: View { } if remainingSeconds < 3600 { let minutes = Int(ceil(Double(remainingSeconds) / 60.0)) - return minutes == 1 - ? String(localized: "about 1 minute") - : String( - format: String(localized: "about %@ minutes"), - minutes.formatted()) + return String( + AttributedString( + localized: "about ^[\(minutes) minute](inflect: true)") + .characters) } let hours = Int(ceil(Double(remainingSeconds) / 3600.0)) - return hours == 1 - ? String(localized: "about 1 hour") - : String( - format: String(localized: "about %@ hours"), - hours.formatted()) + return String( + AttributedString( + localized: "about ^[\(hours) hour](inflect: true)") + .characters) } } diff --git a/apps/ios/WatchApp/Sources/WatchInboxView.swift b/apps/ios/WatchApp/Sources/WatchInboxView.swift index a0a260b926c7..a6fde90fac99 100644 --- a/apps/ios/WatchApp/Sources/WatchInboxView.swift +++ b/apps/ios/WatchApp/Sources/WatchInboxView.swift @@ -470,12 +470,9 @@ private struct WatchControlSurfaceView: View { } private var approvalHeadline: String { - if self.approvalCount == 1 { - return String(localized: "1 approval waiting") - } + let count = self.approvalCount return String( - format: String(localized: "%@ approvals waiting"), - self.approvalCount.formatted()) + AttributedString(localized: "^[\(count) approval](inflect: true) waiting").characters) } private var approvalSubtitle: String { @@ -555,12 +552,9 @@ private struct WatchControlSurfaceView: View { return status } if self.chatCount > 0 { - if self.chatCount == 1 { - return String(localized: "1 recent message") - } + let count = self.chatCount return String( - format: String(localized: "%@ recent messages"), - self.chatCount.formatted()) + AttributedString(localized: "^[\(count) recent message](inflect: true)").characters) } return self.store.hasAppSnapshot ? String(localized: "No messages synced") @@ -1665,9 +1659,9 @@ private struct WatchExecApprovalDetailView: View { if deltaSeconds < 60 { return String(localized: "less than 1 minute") } + let minutes = deltaSeconds / 60 return String( - format: String(localized: "%@ minutes"), - (deltaSeconds / 60).formatted()) + AttributedString(localized: "^[\(minutes) minute](inflect: true)").characters) } } diff --git a/scripts/apple-app-i18n.ts b/scripts/apple-app-i18n.ts index 0930a568992b..33046fb4aa56 100644 --- a/scripts/apple-app-i18n.ts +++ b/scripts/apple-app-i18n.ts @@ -138,8 +138,8 @@ const LOCALIZED_WRAPPER_CONTRACTS: Record = { ], "apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift": [ "private struct ExecApprovalPromptMetadataRow: View {\n let label: LocalizedStringKey", - 'format: String(localized: "about %@ minutes")', - 'format: String(localized: "about %@ hours")', + 'localized: "about ^[\\(minutes) minute](inflect: true)"', + 'localized: "about ^[\\(hours) hour](inflect: true)"', ], "apps/ios/Sources/Voice/TalkGatewayPermissionState.swift": [ 'String(format: String(localized: "Missing %@"), scope)', @@ -156,7 +156,7 @@ const LOCALIZED_WRAPPER_CONTRACTS: Record = { ], "apps/ios/Sources/Design/AgentProTab+Overview.swift": [ "subtitle: .verbatim(self.agentTotalText)", - 'format: String(localized: "%@ agents total")', + 'AttributedString(localized: "^[\\(count) agent](inflect: true) total")', "func agentMenuRow(\n icon: String,\n title: OpenClawTextValue,\n detail: OpenClawTextValue", "func metricTile(\n icon: String,\n title: OpenClawTextValue,\n value: String,\n detail: OpenClawTextValue", ], diff --git a/test/scripts/apple-app-i18n.test.ts b/test/scripts/apple-app-i18n.test.ts index 2b3961707352..ea72012068be 100644 --- a/test/scripts/apple-app-i18n.test.ts +++ b/test/scripts/apple-app-i18n.test.ts @@ -25,8 +25,8 @@ describe("Apple app i18n catalogs", () => { }; for (const key of [ - "%@ agents total", - "%@ approvals waiting", + "^[%lld agent](inflect: true) total", + "^[%lld approval](inflect: true) waiting", "Approval needed", "Agent: %@", "Connect a nearby Gateway",