mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(ios): redesign Control tab to a flatter, card-based layout (#98582)
* feat(ios): refresh Control tab layout Co-authored-by: Raghav Sethi <rsraghavsethi@gmail.com> * fix(ios): align Control card edges --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
parent
4c55d46254
commit
19f42a27e5
3 changed files with 242 additions and 107 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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:"))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue