Files
openclaw/apps/ios/Sources/Design/OpenClawProComponents.swift
Colin Johnson f6e51ff99a feat(ios): refresh pro UI and gateway flows (#87367)
Summary:
- Replace the legacy iOS shell with Pro Command, Chat, Agents, and Settings tabs.
- Wire iOS chat/session/settings/diagnostics and realtime Talk flows through gateway-backed APIs.
- Add gateway/session and shared chat coverage for the new iOS flow.

Verification:
- git diff --check
- node scripts/run-vitest.mjs src/gateway/server.sessions.create.test.ts src/gateway/talk-realtime-relay.test.ts
- swift test --filter ChatViewModelTests (apps/shared/OpenClawKit)
- xcodebuild build for Nimrod's iPhone succeeded; install succeeded; launch was blocked because the phone was locked

Known follow-up:
- Preserve traceLevel in sessions.create parent runtime inheritance and keep the changelog credit in the follow-up patch.
2026-05-28 17:23:26 +03:00

578 lines
18 KiB
Swift

import SwiftUI
enum OpenClawProMetric {
static let pagePadding: CGFloat = 20
static let cardRadius: CGFloat = 14
static let controlRadius: CGFloat = 12
static let bottomScrollInset: CGFloat = 96
static let heroRadius: CGFloat = 22
}
struct OpenClawProBackground: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
LinearGradient(
colors: OpenClawBrand.canvasColors(for: self.colorScheme),
startPoint: .top,
endPoint: .bottom)
.ignoresSafeArea()
.overlay(alignment: .top) {
if self.colorScheme == .light {
LinearGradient(
colors: [
OpenClawBrand.accent.opacity(0.05),
.clear,
],
startPoint: .topTrailing,
endPoint: .bottomLeading)
.frame(height: 260)
.ignoresSafeArea()
}
}
}
}
struct ProSectionHeader: View {
let title: String
var actionTitle: String?
var action: (() -> Void)?
var uppercase = true
var body: some View {
HStack {
Text(self.title)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.textCase(self.uppercase ? .uppercase : nil)
Spacer()
if let actionTitle {
if let action {
Button(actionTitle, action: action)
.font(.caption.weight(.medium))
.foregroundStyle(OpenClawBrand.accent)
} else {
Text(actionTitle)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
struct ProCard<Content: View>: View {
var tint: Color?
var isProminent: Bool = false
var padding: CGFloat = 14
var radius: CGFloat = OpenClawProMetric.cardRadius
@ViewBuilder var content: Content
var body: some View {
self.content
.padding(self.padding)
.frame(maxWidth: .infinity, alignment: .leading)
.proPanelSurface(
tint: self.tint,
radius: self.radius,
isProminent: self.isProminent)
}
}
private struct ProPanelBackground: View {
@Environment(\.colorScheme) private var colorScheme
let radius: CGFloat
let tint: Color?
let isProminent: Bool
var body: some View {
let shape = RoundedRectangle(cornerRadius: self.radius, style: .continuous)
shape
.fill(self.fill)
.overlay {
ProPanelTexture()
.opacity(self.colorScheme == .dark ? 0.22 : 0.08)
.clipShape(shape)
}
.overlay {
shape.strokeBorder(self.borderStyle, lineWidth: 1)
}
.overlay {
shape
.strokeBorder(Color.black.opacity(self.colorScheme == .dark ? 0.40 : 0.055), lineWidth: 0.7)
.padding(1)
}
.overlay(alignment: .top) {
shape
.strokeBorder(Color.white.opacity(self.colorScheme == .dark ? 0.07 : 0.36), lineWidth: 0.7)
.mask(alignment: .top) {
Rectangle().frame(height: 28)
}
}
}
private var fill: AnyShapeStyle {
if self.colorScheme == .dark {
let base = self.isProminent
? Color(red: 15 / 255, green: 17 / 255, blue: 19 / 255)
: Color(red: 10 / 255, green: 12 / 255, blue: 14 / 255)
return AnyShapeStyle(base)
}
let gradient = LinearGradient(
colors: [
Color.white.opacity(0.98),
(self.tint ?? Color.white).opacity(self.tint == nil ? 0.92 : 0.12),
Color(red: 246 / 255, green: 247 / 255, blue: 249 / 255),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
return AnyShapeStyle(gradient)
}
private var borderStyle: AnyShapeStyle {
if self.colorScheme == .dark {
return AnyShapeStyle(Color.white.opacity(self.isProminent ? 0.15 : 0.11))
}
let gradient = LinearGradient(
colors: [
Color.white.opacity(0.72),
(self.tint ?? OpenClawBrand.accent).opacity(0.10),
Color.black.opacity(0.08),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
return AnyShapeStyle(gradient)
}
}
private struct ProPanelTexture: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Canvas { context, size in
let color = self.colorScheme == .dark ? Color.white.opacity(0.11) : Color.black.opacity(0.08)
for y in stride(from: 2.0, through: size.height, by: 6.5) {
let offset = Int(y / 6.5).isMultiple(of: 2) ? 0.0 : 3.25
for x in stride(from: 2.0 + offset, through: size.width, by: 6.5) {
let dot = CGRect(x: x, y: y, width: 0.7, height: 0.7)
context.fill(Path(ellipseIn: dot), with: .color(color))
}
}
}
}
}
private struct ProLightGlassModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
let radius: CGFloat
func body(content: Content) -> some View {
if #available(iOS 26.0, *), self.colorScheme == .light {
content.glassEffect(.regular, in: .rect(cornerRadius: self.radius))
} else {
content
}
}
}
private struct ProGlassSurfaceModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
let fill: Color
let stroke: Color
let radius: CGFloat
let isProminent: Bool
var interactive = false
func body(content: Content) -> some View {
let shape = RoundedRectangle(cornerRadius: self.radius, style: .continuous)
let surfaced = content.background {
shape
.fill(self.fill)
.overlay {
shape.strokeBorder(self.stroke, lineWidth: self.isProminent ? 1.2 : 1)
}
}
if #available(iOS 26.0, *), self.colorScheme == .light {
surfaced.glassEffect(
self.interactive ? .regular.interactive() : .regular,
in: .rect(cornerRadius: self.radius))
} else {
surfaced
}
}
}
extension View {
func proPanelSurface(
tint: Color? = nil,
radius: CGFloat = OpenClawProMetric.cardRadius,
isProminent: Bool = false) -> some View
{
self.modifier(ProPanelSurfaceModifier(
tint: tint,
radius: radius,
isProminent: isProminent))
}
func proGlassSurface(
fill: Color,
stroke: Color,
radius: CGFloat,
isProminent: Bool = false,
interactive: Bool = false) -> some View
{
self.modifier(ProGlassSurfaceModifier(
fill: fill,
stroke: stroke,
radius: radius,
isProminent: isProminent,
interactive: interactive))
}
}
private struct ProPanelSurfaceModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
let tint: Color?
let radius: CGFloat
let isProminent: Bool
func body(content: Content) -> some View {
content
.background {
ProPanelBackground(
radius: self.radius,
tint: self.tint,
isProminent: self.isProminent)
}
.modifier(ProLightGlassModifier(radius: self.radius))
.shadow(
color: self.colorScheme == .dark ? .black.opacity(0.60) : .black.opacity(0.045),
radius: self.isProminent ? 20 : 12,
y: self.isProminent ? 10 : 6)
}
}
struct ProIconBadge: View {
let systemName: String
let color: Color
var body: some View {
Image(systemName: self.systemName)
.font(.subheadline.weight(.semibold))
.foregroundStyle(self.color)
.frame(width: 34, height: 34)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.color.opacity(0.12))
}
}
}
struct ProStatusDot: View {
var color: Color
var body: some View {
Circle()
.fill(self.color)
.frame(width: 8, height: 8)
.shadow(color: self.color.opacity(0.35), radius: 4)
}
}
struct ProValuePill: View {
@Environment(\.colorScheme) private var colorScheme
let value: String
let color: Color
var body: some View {
Text(self.value)
.font(.caption.weight(.semibold))
.foregroundStyle(self.color)
.lineLimit(1)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background {
Capsule()
.fill(self.color.opacity(self.colorScheme == .dark ? 0.12 : 0.08))
}
}
}
struct OpenClawProMark: View {
var size: CGFloat = 42
var shadowRadius: CGFloat = 10
var body: some View {
Image("OpenClawIcon")
.resizable()
.scaledToFit()
.frame(width: self.size, height: self.size)
.shadow(color: OpenClawBrand.accent.opacity(0.28), radius: self.shadowRadius, y: self.shadowRadius / 2)
.accessibilityLabel("OpenClaw")
}
}
struct ProProgressBar: View {
let progress: Double
var color: Color = OpenClawBrand.accentHot
var body: some View {
GeometryReader { proxy in
let clamped = max(0, min(self.progress, 1))
ZStack(alignment: .leading) {
Capsule()
.fill(Color.primary.opacity(0.10))
Capsule()
.fill(self.color)
.frame(width: proxy.size.width * clamped)
}
}
.frame(height: 3)
}
}
struct ProWorkRow: View {
let icon: String
let title: String
let detail: String
let state: String
let trailing: String
let color: Color
var progress: Double?
var body: some View {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: self.icon, color: self.color)
VStack(alignment: .leading, spacing: 5) {
HStack(alignment: .firstTextBaseline) {
Text(self.title)
.font(.subheadline.weight(.semibold))
Spacer(minLength: 8)
Text(self.trailing)
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
HStack(spacing: 8) {
if let progress {
ProProgressBar(progress: progress, color: self.color)
.frame(maxWidth: 120)
}
Text(self.state)
.font(.caption2.weight(.semibold))
.foregroundStyle(self.color)
}
}
}
.padding(.vertical, 9)
}
}
struct ProCapsule: View {
@Environment(\.colorScheme) private var colorScheme
let title: String
let color: Color
var icon: String?
var body: some View {
HStack(spacing: 6) {
if let icon {
Image(systemName: icon)
.font(.caption.weight(.semibold))
}
Text(self.title)
.font(.caption.weight(.semibold))
}
.foregroundStyle(self.color)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background {
Capsule()
.fill(self.color.opacity(self.colorScheme == .dark ? 0.16 : 0.10))
.overlay {
Capsule()
.strokeBorder(self.color.opacity(self.colorScheme == .dark ? 0.30 : 0.18), lineWidth: 1)
}
}
}
}
struct ProSegmentedControl: View {
@Environment(\.colorScheme) private var colorScheme
let labels: [String]
@Binding var selection: Int
var body: some View {
HStack(spacing: 4) {
ForEach(Array(self.labels.enumerated()), id: \.offset) { index, label in
Button {
self.selection = index
} label: {
Text(label)
.font(.subheadline.weight(self.selection == index ? .semibold : .regular))
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(self.segmentFill(isSelected: self.selection == index), in: Capsule())
}
.buttonStyle(.plain)
}
}
.padding(4)
.background {
Capsule()
.fill(self.trackFill)
.overlay {
Capsule().strokeBorder(self.trackStroke, lineWidth: 1)
}
}
}
private func segmentFill(isSelected: Bool) -> Color {
guard isSelected else { return .clear }
return self.colorScheme == .dark ? Color.white.opacity(0.12) : Color.primary.opacity(0.08)
}
private var trackFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.72)
}
private var trackStroke: Color {
self.colorScheme == .dark ? Color.white.opacity(0.10) : Color.black.opacity(0.06)
}
}
struct ProHeroActionButton: View {
@Environment(\.colorScheme) private var colorScheme
let title: String
let detail: String
let systemImage: String
let action: () -> Void
var body: some View {
Button(action: self.action) {
HStack(spacing: 12) {
Image(systemName: self.systemImage)
.font(.headline.weight(.semibold))
.foregroundStyle(.white)
.frame(width: 42, height: 42)
.background(OpenClawBrand.accentHot, in: RoundedRectangle(cornerRadius: 13, style: .continuous))
VStack(alignment: .leading, spacing: 3) {
Text(self.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Image(systemName: "arrow.right")
.font(.subheadline.weight(.bold))
.foregroundStyle(OpenClawBrand.accentHot)
}
.padding(12)
.proGlassSurface(
fill: self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.68),
stroke: OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.22 : 0.14),
radius: 18,
isProminent: true,
interactive: true)
}
.buttonStyle(.plain)
}
}
struct ProMetricTile: View {
@Environment(\.colorScheme) private var colorScheme
let title: String
let value: String
let icon: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Image(systemName: self.icon)
.font(.caption.weight(.semibold))
.foregroundStyle(self.color)
.frame(width: 24, height: 24)
.background(self.color.opacity(self.colorScheme == .dark ? 0.18 : 0.10), in: Circle())
Spacer(minLength: 4)
}
VStack(alignment: .leading, spacing: 2) {
Text(self.value)
.font(.headline.weight(.bold))
.lineLimit(1)
.minimumScaleFactor(0.72)
Text(self.title)
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(11)
.frame(maxWidth: .infinity, alignment: .leading)
.proGlassSurface(
fill: self.colorScheme == .dark ? Color.white.opacity(0.04) : Color.white.opacity(0.52),
stroke: self.color.opacity(self.colorScheme == .dark ? 0.18 : 0.10),
radius: 16)
}
}
struct ProStatusRow: View {
let icon: String
let title: String
let detail: String
let value: String
let color: Color
var body: some View {
HStack(alignment: .center, spacing: 12) {
ProIconBadge(systemName: self.icon, color: self.color)
VStack(alignment: .leading, spacing: 4) {
Text(self.title)
.font(.subheadline.weight(.semibold))
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
ProValuePill(value: self.value, color: self.color)
}
.padding(.vertical, 11)
}
}
struct ProTimelineRow: View {
let done: Bool
let title: String
let detail: String
var body: some View {
HStack(alignment: .top, spacing: 10) {
ProIconBadge(
systemName: self.done ? "checkmark.circle.fill" : "clock.fill",
color: self.done ? OpenClawBrand.ok : OpenClawBrand.warn)
VStack(alignment: .leading, spacing: 3) {
Text(self.title)
.font(.subheadline.weight(.medium))
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}