From 19f42a27e5b091ddbda77d425366cb6bda461198 Mon Sep 17 00:00:00 2001 From: Raghav Sethi Date: Thu, 9 Jul 2026 19:38:07 +0530 Subject: [PATCH] feat(ios): redesign Control tab to a flatter, card-based layout (#98582) * feat(ios): refresh Control tab layout Co-authored-by: Raghav Sethi * fix(ios): align Control card edges --------- Co-authored-by: Peter Steinberger --- apps/.i18n/native-source.json | 80 ++++-- .../Design/RootTabsPhoneControlHub.swift | 248 ++++++++++++------ apps/ios/Tests/RootTabsSourceGuardTests.swift | 21 +- 3 files changed, 242 insertions(+), 107 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 00cca0eeec7..721a3c4bd80 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -9035,31 +9035,15 @@ }, { "kind": "ui-modifier", - "line": 43, + "line": 41, "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "source": "Control", "surface": "apple", "id": "native.apple.a2d8e7b4dba29764" }, - { - "kind": "ui-call", - "line": 64, - "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", - "source": "Gateway", - "surface": "apple", - "id": "native.apple.e0e99a802f07cc68" - }, { "kind": "ui-modifier", - "line": 86, - "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", - "source": "Gateway \\(self.gatewayStateText), \\(self.sidebarActiveAgentTitle)", - "surface": "apple", - "id": "native.apple.fe4468ab02aa489d" - }, - { - "kind": "ui-modifier", - "line": 87, + "line": 91, "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "source": "Opens Settings / Gateway", "surface": "apple", @@ -9067,7 +9051,23 @@ }, { "kind": "ui-named-argument", - "line": 134, + "line": 100, + "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", + "source": "Agent chat and recent work.", + "surface": "apple", + "id": "native.apple.ed16c9793f468dcd" + }, + { + "kind": "ui-named-argument", + "line": 103, + "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", + "source": "Realtime voice and controls.", + "surface": "apple", + "id": "native.apple.a30d0a950d0a77aa" + }, + { + "kind": "ui-named-argument", + "line": 173, "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "source": "Overview", "surface": "apple", @@ -9075,7 +9075,7 @@ }, { "kind": "ui-named-argument", - "line": 156, + "line": 195, "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "source": "Instances", "surface": "apple", @@ -9083,7 +9083,7 @@ }, { "kind": "ui-named-argument", - "line": 165, + "line": 204, "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "source": "Files", "surface": "apple", @@ -9091,7 +9091,7 @@ }, { "kind": "ui-named-argument", - "line": 170, + "line": 209, "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "source": "Dreaming", "surface": "apple", @@ -9099,7 +9099,7 @@ }, { "kind": "ui-named-argument", - "line": 175, + "line": 214, "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "source": "Usage", "surface": "apple", @@ -9107,15 +9107,39 @@ }, { "kind": "ui-named-argument", - "line": 180, + "line": 219, "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "source": "Cron Jobs", "surface": "apple", "id": "native.apple.9eb33a11fb81796b" }, + { + "kind": "ui-call", + "line": 299, + "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", + "source": "Gateway", + "surface": "apple", + "id": "native.apple.e0e99a802f07cc68" + }, + { + "kind": "ui-call", + "line": 306, + "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", + "source": "Gateway \\(self.gatewayStateText), \\(gatewayDisplayLabel), \\(self.sidebarActiveAgentTitle)", + "surface": "apple", + "id": "native.apple.e30d7d35682e8ffa" + }, + { + "kind": "ui-call", + "line": 309, + "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", + "source": "Gateway \\(self.gatewayStateText), \\(self.sidebarActiveAgentTitle)", + "surface": "apple", + "id": "native.apple.75b65c24f3607c35" + }, { "kind": "conditional-branch", - "line": 254, + "line": 316, "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "source": "Online", "surface": "apple", @@ -9123,7 +9147,7 @@ }, { "kind": "conditional-branch", - "line": 255, + "line": 317, "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "source": "Connecting", "surface": "apple", @@ -9131,7 +9155,7 @@ }, { "kind": "conditional-branch", - "line": 256, + "line": 318, "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "source": "Attention", "surface": "apple", @@ -9139,7 +9163,7 @@ }, { "kind": "conditional-branch", - "line": 257, + "line": 319, "path": "apps/ios/Sources/Design/RootTabsPhoneControlHub.swift", "source": "Offline", "surface": "apple", diff --git a/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift b/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift index 15f8abe831e..db5bc6e74bc 100644 --- a/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift +++ b/apps/ios/Sources/Design/RootTabsPhoneControlHub.swift @@ -3,9 +3,6 @@ import SwiftUI struct RootTabsPhoneControlHub: View { @Environment(NodeAppModel.self) private var appModel - @State private var navigationPath: [RootTabs.SidebarDestination] = [] - @State private var didApplyInitialDestination = false - @State private var handledNavigationRequestID = 0 let groups: [RootTabs.SidebarGroup] let initialDestination: RootTabs.SidebarDestination? @@ -13,29 +10,30 @@ struct RootTabsPhoneControlHub: View { let openRootDestination: (RootTabs.SidebarDestination) -> Void let openChatFromControlDetail: (RootTabs.SidebarDestination) -> Void + @State private var navigationPath: [RootTabs.SidebarDestination] = [] + @State private var didApplyInitialDestination = false + @State private var handledNavigationRequestID = 0 + var body: some View { NavigationStack(path: self.$navigationPath) { List { Section { - Button { - self.openGatewayDetail() - } label: { - self.gatewayRow - } - .buttonStyle(.plain) + self.gatewayHeader + .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } - ForEach(self.phoneGroups) { group in - Section { - ForEach(group.destinations) { destination in - self.destinationRow(destination) - } - } header: { - if let title = self.sectionTitle(for: group) { - Text(title) - .font(OpenClawType.captionSemiBold) - .foregroundStyle(.secondary) - } + Section { + self.chatTalkRow + .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + + Section { + ForEach(self.phoneDestinations) { destination in + self.destinationRow(destination) } } } @@ -55,59 +53,97 @@ struct RootTabsPhoneControlHub: View { } } - private var gatewayRow: some View { - HStack(spacing: 12) { - ProIconBadge( - systemName: "antenna.radiowaves.left.and.right", - color: self.gatewayStateColor) - VStack(alignment: .leading, spacing: 2) { - Text("Gateway") - .font(OpenClawType.subheadSemiBold) - .foregroundStyle(.primary) - Text(self.sidebarActiveAgentTitle) - .font(OpenClawType.footnote) + private var gatewayHeader: some View { + Button { + self.openGatewayDetail() + } label: { + HStack(spacing: 12) { + OpenClawProMark(size: 44, shadowRadius: 5) + VStack(alignment: .leading, spacing: 3) { + self.gatewayIdentityTitle + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.middle) + HStack(spacing: 4) { + Text(self.sidebarActiveAgentTitle) + Text("•") + .accessibilityHidden(true) + Text(self.gatewayStateText) + .foregroundStyle(self.gatewayStateColor) + } + .font(OpenClawType.captionMedium) .foregroundStyle(.secondary) .lineLimit(1) + } + Spacer(minLength: 8) + Image(systemName: "slider.horizontal.3") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 44, height: 44) + .background(Color.primary.opacity(0.06), in: Circle()) } - Spacer(minLength: 8) - HStack(spacing: 6) { - ProStatusDot(color: self.gatewayStateColor) - Text(self.gatewayStateText) - .font(OpenClawType.footnoteSemiBold) - .foregroundStyle(self.gatewayStateColor) - Image(systemName: "chevron.right") - .font(OpenClawType.captionSemiBold) - .foregroundStyle(.secondary) - } + .padding(.vertical, 6) + .contentShape(Rectangle()) } - .padding(.vertical, 4) - .contentShape(Rectangle()) + .buttonStyle(.plain) .accessibilityElement(children: .combine) - .accessibilityLabel("Gateway \(self.gatewayStateText), \(self.sidebarActiveAgentTitle)") + .accessibilityLabel(self.gatewayAccessibilityLabel) .accessibilityHint("Opens Settings / Gateway") } - @ViewBuilder - private func destinationRow(_ destination: RootTabs.SidebarDestination) -> some View { - if self.opensRootTab(destination) { - Button { - self.openPhoneRootDestination(destination) - } label: { - HStack(spacing: 12) { - self.rowLabel(destination) - Spacer(minLength: 8) - Image(systemName: "chevron.right") - .font(OpenClawType.captionSemiBold) - .foregroundStyle(.secondary) + private var chatTalkRow: some View { + // Chat and Talk intentionally stay as Control shortcuts even though they own root tabs. + // These are the hub's primary actions; the remaining destination list filters root tabs. + HStack(alignment: .top, spacing: 12) { + self.prominentDestinationCard( + .chat, + subtitle: "Agent chat and recent work.") + self.prominentDestinationCard( + .talk, + subtitle: "Realtime voice and controls.") + } + } + + private func prominentDestinationCard( + _ destination: RootTabs.SidebarDestination, + subtitle: LocalizedStringKey) -> some View + { + Button { + self.openPhoneRootDestination(destination) + } label: { + ProCard(padding: 16, radius: OpenClawProMetric.cardRadius) { + VStack(alignment: .leading, spacing: 12) { + ControlCircleIcon( + systemName: destination.systemImage, + color: self.color(for: destination), + size: 46) + HStack(alignment: .top, spacing: 6) { + VStack(alignment: .leading, spacing: 2) { + Text(destination.title) + .font(OpenClawType.headline) + .foregroundStyle(.primary) + Text(subtitle) + .font(OpenClawType.caption2) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer(minLength: 4) + Image(systemName: "chevron.right") + .font(OpenClawType.caption2Bold) + .foregroundStyle(.secondary) + } } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } else { - NavigationLink(value: destination) { - self.rowLabel(destination) + .frame(maxWidth: .infinity, minHeight: 128, alignment: .leading) } } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func destinationRow(_ destination: RootTabs.SidebarDestination) -> some View { + NavigationLink(value: destination) { + self.rowLabel(destination) + } } private func rowLabel(_ destination: RootTabs.SidebarDestination) -> some View { @@ -116,7 +152,10 @@ struct RootTabsPhoneControlHub: View { .font(OpenClawType.subheadSemiBold) .foregroundStyle(.primary) } icon: { - ProIconBadge(systemName: destination.systemImage, color: .secondary) + ControlCircleIcon( + systemName: destination.systemImage, + color: self.color(for: destination), + size: 34) } } @@ -207,12 +246,8 @@ struct RootTabsPhoneControlHub: View { RootTabs.shouldOpenRootTabFromPhoneHub(destination) } - private var phoneGroups: [RootTabs.SidebarGroup] { - self.groups.compactMap { group in - let destinations = group.destinations.filter { !self.opensRootTab($0) } - guard !destinations.isEmpty else { return nil } - return RootTabs.SidebarGroup(title: group.title, destinations: destinations) - } + private var phoneDestinations: [RootTabs.SidebarDestination] { + self.groups.flatMap(\.destinations).filter { !self.opensRootTab($0) } } private func applyInitialDestinationIfNeeded() { @@ -249,6 +284,33 @@ struct RootTabsPhoneControlHub: View { return self.normalized(self.appModel.activeAgentName) ?? "Default Agent" } + private var gatewayDisplayLabel: String? { + self.normalized(self.appModel.gatewayServerName) + ?? self.normalized(self.appModel.gatewayRemoteAddress) + } + + @ViewBuilder + private var gatewayIdentityTitle: some View { + // Gateway names are server data; only the product fallback is localizable. + if let gatewayDisplayLabel { + Text(verbatim: gatewayDisplayLabel) + .font(OpenClawType.headlineBold) + } else { + Text("Gateway") + .font(OpenClawType.headlineBold) + } + } + + private var gatewayAccessibilityLabel: Text { + if let gatewayDisplayLabel { + Text("Gateway \(self.gatewayStateText), \(gatewayDisplayLabel), \(self.sidebarActiveAgentTitle)") + .font(OpenClawType.captionMedium) + } else { + Text("Gateway \(self.gatewayStateText), \(self.sidebarActiveAgentTitle)") + .font(OpenClawType.captionMedium) + } + } + private var gatewayStateText: String { switch GatewayStatusBuilder.build(appModel: self.appModel) { case .connected: "Online" @@ -271,11 +333,24 @@ struct RootTabsPhoneControlHub: View { } } - private func sectionTitle(for group: RootTabs.SidebarGroup) -> String? { - switch group.title.lowercased() { - case "chat": "Communication" - case "control": nil - default: group.title.capitalized + private func color(for destination: RootTabs.SidebarDestination) -> Color { + switch destination { + case .chat: + OpenClawBrand.ok + case .talk, .skillWorkshop, .files: + OpenClawBrand.info + case .overview: + OpenClawBrand.warn + case .activity: + OpenClawBrand.accent + case .workboard: + .purple + case .instances, .sessions, .dreaming, .terminal: + .secondary + case .usage, .docs: + OpenClawBrand.accentHot + case .agents, .cron, .settings, .gateway: + OpenClawBrand.ok } } @@ -295,6 +370,31 @@ struct RootTabsPhoneControlHub: View { } } +private struct ControlCircleIcon: View { + @Environment(\.colorScheme) private var colorScheme + + let systemName: String + let color: Color + let size: CGFloat + + var body: some View { + Image(systemName: self.systemName) + .font(.system(size: self.size * 0.42, weight: .semibold)) + .foregroundStyle(self.iconForegroundStyle) + .frame(width: self.size, height: self.size) + .background( + LinearGradient( + colors: [self.color.opacity(0.72), self.color], + startPoint: .topLeading, + endPoint: .bottomTrailing), + in: Circle()) + } + + private var iconForegroundStyle: Color { + self.colorScheme == .dark ? .black.opacity(0.82) : .white + } +} + #if DEBUG #Preview("Phone control hub offline") { RootTabsPhoneControlHub.preview(appModel: NodeAppModel()) diff --git a/apps/ios/Tests/RootTabsSourceGuardTests.swift b/apps/ios/Tests/RootTabsSourceGuardTests.swift index 72322cacdb5..2fef4e67773 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests.swift @@ -382,22 +382,33 @@ struct RootTabsSourceGuardTests { #expect(!source.contains("safeAreaPadding(.bottom")) } - @Test func `phone hub stays task first without duplicating root tabs`() throws { + @Test func `phone hub promotes chat and talk while filtering root tabs from its destination list`() throws { let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8) - #expect(source.contains("private var gatewayRow: some View")) - #expect(source.contains(".accessibilityLabel(\"Gateway \\(self.gatewayStateText),")) + #expect(source.contains("private var gatewayHeader: some View")) + #expect(source.contains("private var gatewayIdentityTitle: some View")) + #expect(source.contains("Text(verbatim: gatewayDisplayLabel)")) + #expect(source.contains("Text(\"Gateway\")")) + #expect(source.contains(".accessibilityLabel(self.gatewayAccessibilityLabel)")) + #expect(source.contains("private var chatTalkRow: some View")) + #expect(source.contains( + ".listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))")) + #expect(source.contains("self.prominentDestinationCard(\n .chat,")) + #expect(source.contains("self.prominentDestinationCard(\n .talk,")) + #expect(source.contains("private var phoneDestinations: [RootTabs.SidebarDestination]")) + #expect(source.contains("self.groups.flatMap(\\.destinations).filter { !self.opensRootTab($0) }")) + #expect(source.contains("private struct ControlCircleIcon: View")) + #expect(source.contains(".foregroundStyle(self.iconForegroundStyle)")) #expect(!source.contains("ProValuePill(value: self.gatewayStateText")) #expect(!source.contains("destination.subtitle")) #expect(source.contains("self.openGatewayDetail()")) #expect(!source.contains("self.openPhoneRootDestination(.gateway)")) - #expect(source.contains("group.destinations.filter { !self.opensRootTab($0) }")) #expect(!source.contains("phoneDetailBackAction")) #expect(!source.contains(".navigationBarBackButtonHidden(true)")) #expect(!source.contains(".toolbar(.hidden, for: .navigationBar)")) #expect(source.matches(of: /usesNativeNavigationChrome: true/).count == 7) #expect(!source.contains("directRoute: .agents")) - #expect(!source.contains("Image(systemName: \"gearshape\")")) + #expect(source.contains("Image(systemName: \"slider.horizontal.3\")")) #expect(!source.contains("self.metric(label:")) #expect(!source.contains("private func metric(label:")) }