mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(ios): modernize the app with iOS 26 Liquid Glass (#98452)
* feat(ios): adopt iOS 26 Liquid Glass design * refactor(ios): refine Liquid Glass hierarchy * docs(ios): require Xcode 26 * refactor(ios): remove obsolete design helpers * fix(ios): keep agent tab navigation hidden * fix(ios): compact chat composer at rest * refactor(ios): reduce interface density * fix(ios): keep native string inventory current
This commit is contained in:
parent
68bfa42b9b
commit
8502ef6c59
29 changed files with 1675 additions and 1618 deletions
File diff suppressed because it is too large
Load diff
55
apps/ios/DESIGN.md
Normal file
55
apps/ios/DESIGN.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# iOS design system
|
||||
|
||||
OpenClaw follows the native iOS 26 design language while keeping an iOS 18 deployment target. Use SwiftUI system structure first, Liquid Glass for interactive chrome, and quiet opaque surfaces for content.
|
||||
|
||||
## Principles
|
||||
|
||||
- Prefer `NavigationStack`, `TabView`, `List`, `Form`, toolbars, sheets, and system controls. They adopt the current platform appearance automatically.
|
||||
- Reserve Liquid Glass for navigation and interactive controls. Do not apply glass to every card, row, or status surface.
|
||||
- Keep content hierarchy clear with typography, spacing, and grouping before adding backgrounds.
|
||||
- Use semantic colors. Red means destructive or stopped; orange means attention; green means healthy. Neutral actions use the app accent.
|
||||
- Preserve Dynamic Type, VoiceOver labels, Reduce Motion, increased contrast, and 44-point touch targets.
|
||||
- Use continuous corners and concentric geometry. Nested controls should visually follow their container shape.
|
||||
|
||||
Apple references: [Adopting Liquid Glass](https://developer.apple.com/documentation/technologyoverviews/adopting-liquid-glass), [Applying Liquid Glass to custom views](https://developer.apple.com/documentation/swiftui/applying-liquid-glass-to-custom-views), and [Build a SwiftUI app with the new design](https://developer.apple.com/videos/play/wwdc2025/323/).
|
||||
|
||||
## Tokens
|
||||
|
||||
`OpenClawProMetric` in `Sources/Design/OpenClawProComponents.swift` is the source of truth for shared geometry:
|
||||
|
||||
- `pagePadding`: standard page gutter
|
||||
- `cardRadius`: content group radius
|
||||
- `controlRadius`: inset control radius
|
||||
- `compactControlSize`: compact circular control size
|
||||
- `bottomScrollInset`: clearance above persistent navigation
|
||||
|
||||
Feature-local layout enums may define row heights and grid dimensions, but should reference the shared radius instead of introducing a new card shape.
|
||||
|
||||
## Components
|
||||
|
||||
- `OpenClawProBackground`: grouped page background
|
||||
- `ProCard`: quiet content grouping; never Liquid Glass
|
||||
- `ProIconBadge`, `ProValuePill`: compact semantic indicators
|
||||
- `OpenClawNoticeBanner`: shared connection and runtime notices
|
||||
- `OpenClawAdaptiveHeaderRow`: responsive destination heading
|
||||
- `OpenClawGlassControlGroup`: performance and morphing boundary for nearby glass controls
|
||||
- `openClawGlassButton(prominent:tint:)`: iOS 26 glass button with an iOS 18 bordered fallback
|
||||
- `openClawTabBarBehavior()`: iOS 26 tab-bar minimization with an earlier-system no-op
|
||||
|
||||
## Liquid Glass rules
|
||||
|
||||
Use `openClawGlassButton` for primary actions, compact header controls, and navigation-adjacent controls. Use the prominent style for one primary action per region. Wrap nearby controls in `OpenClawGlassControlGroup`.
|
||||
|
||||
Do not place Liquid Glass behind reading content, forms, metrics, or every card in a scroll view. Excess glass weakens hierarchy, increases rendering cost, and competes with the system tab bar and navigation chrome.
|
||||
|
||||
Keep new iOS APIs behind `#available(iOS 26.0, *)`. The fallback must preserve the same label, action, tint meaning, accessibility, and approximate hit target.
|
||||
|
||||
## Review checklist
|
||||
|
||||
- Uses a native container or control where one exists.
|
||||
- Uses shared spacing and corner tokens.
|
||||
- Has one obvious primary action.
|
||||
- Keeps semantic color independent from decoration.
|
||||
- Works in light and dark mode, Dynamic Type, and compact phone layouts.
|
||||
- Verifies iOS 26 appearance in the simulator and preserves the iOS 18 fallback path.
|
||||
- Adds matched before/after evidence for a visual change.
|
||||
|
|
@ -18,7 +18,7 @@ This iOS app is super-alpha and internal-use only. The first public App Store re
|
|||
## Exact Xcode Manual Deploy Flow
|
||||
|
||||
1. Prereqs:
|
||||
- Xcode 16+
|
||||
- Xcode 26.x
|
||||
- `pnpm`
|
||||
- `xcodegen`
|
||||
- Apple Development signing set up in Xcode
|
||||
|
|
@ -51,7 +51,7 @@ pnpm ios:open
|
|||
|
||||
Prereqs:
|
||||
|
||||
- Xcode 16+
|
||||
- Xcode 26.x
|
||||
- `pnpm`
|
||||
- `xcodegen`
|
||||
- `fastlane`
|
||||
|
|
|
|||
|
|
@ -37,8 +37,9 @@ extension AgentProTab {
|
|||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Agents")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
// The roster owns its large adaptive header; a second navigation title
|
||||
// wastes vertical space and makes the phone tab look double-stacked.
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
|
||||
var skillsDestination: some View {
|
||||
|
|
|
|||
|
|
@ -29,32 +29,20 @@ extension AgentProTab {
|
|||
|
||||
func agentDetail(for agent: AgentSummary) -> String {
|
||||
let parts = [
|
||||
self.normalized(agent.workspace),
|
||||
self.modelLabel(for: agent),
|
||||
agent.id == self.appModel.gatewayDefaultAgentId ? "default" : nil,
|
||||
agent.id == self.appModel.gatewayDefaultAgentId ? "Default" : nil,
|
||||
].compactMap(\.self)
|
||||
return parts.isEmpty ? agent.id : parts.joined(separator: " • ")
|
||||
}
|
||||
|
||||
func agentSessionSummary(_ agent: AgentSummary) -> String {
|
||||
guard self.gatewayConnected else { return "0" }
|
||||
if agent.id == self.activeAgentID {
|
||||
return self.appModel.isOperatorGatewayConnected ? "1 running" : "0"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
func agentRuntimeSummary(_ agent: AgentSummary) -> String {
|
||||
if let runtime = agent.agentruntime,
|
||||
let id = runtime["id"]?.value as? String,
|
||||
let normalized = self.normalized(id)
|
||||
{
|
||||
return normalized
|
||||
}
|
||||
if let model = self.modelLabel(for: agent) {
|
||||
return Self.shortModelLabel(model)
|
||||
}
|
||||
return "default"
|
||||
func agentAccessibilityLabel(
|
||||
_ agent: AgentSummary,
|
||||
isActive: Bool,
|
||||
state: AgentRosterState) -> String
|
||||
{
|
||||
let status = state == .online ? "Online" : "Ready"
|
||||
let selection = isActive ? "Selected" : "Not selected"
|
||||
return "\(self.agentName(for: agent)), \(self.agentDetail(for: agent)), \(status), \(selection)"
|
||||
}
|
||||
|
||||
func agentRosterState(for agent: AgentSummary) -> AgentRosterState {
|
||||
|
|
@ -75,15 +63,6 @@ extension AgentProTab {
|
|||
return nil
|
||||
}
|
||||
|
||||
static func shortModelLabel(_ model: String) -> String {
|
||||
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "default" }
|
||||
let leaf = trimmed.split(separator: "/").last.map(String.init) ?? trimmed
|
||||
return leaf
|
||||
.replacingOccurrences(of: "claude-", with: "")
|
||||
.replacingOccurrences(of: "gpt-", with: "")
|
||||
}
|
||||
|
||||
func presenceLabel(_ entry: PresenceEntry) -> String? {
|
||||
self.normalized(entry.host)
|
||||
?? self.normalized(entry.devicefamily)
|
||||
|
|
|
|||
|
|
@ -16,22 +16,18 @@ extension AgentProTab {
|
|||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
} accessory: {
|
||||
HStack(spacing: 10) {
|
||||
self.gatewayPillButton
|
||||
self.headerIconButton(
|
||||
systemName: "magnifyingglass",
|
||||
label: "Search agents",
|
||||
action: {
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
self.agentSearchPresented.toggle()
|
||||
}
|
||||
})
|
||||
self.headerIconButton(
|
||||
systemName: "arrow.clockwise",
|
||||
label: self.overviewLoading ? "Refreshing agents" : "Refresh agents",
|
||||
action: {
|
||||
self.overviewRefreshNonce += 1
|
||||
})
|
||||
OpenClawGlassControlGroup {
|
||||
HStack(spacing: 10) {
|
||||
self.gatewayPillButton
|
||||
self.headerIconButton(
|
||||
systemName: "magnifyingglass",
|
||||
label: "Search agents",
|
||||
action: {
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
self.agentSearchPresented.toggle()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
|
@ -41,15 +37,8 @@ extension AgentProTab {
|
|||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(.subheadline)
|
||||
.padding(.horizontal, 12)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(height: 38)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.searchFieldFill)
|
||||
.overlay {
|
||||
Capsule().strokeBorder(self.searchFieldStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
|
|
@ -63,7 +52,8 @@ extension AgentProTab {
|
|||
Button(action: openSettings) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonBorderShape(.capsule)
|
||||
.openClawGlassButton()
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
|
|
@ -71,46 +61,32 @@ extension AgentProTab {
|
|||
}
|
||||
|
||||
var agentFilters: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
Picker("Agent status", selection: self.$agentRosterFilter) {
|
||||
ForEach(AgentRosterFilter.allCases) { filter in
|
||||
Button {
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
self.agentRosterFilter = filter
|
||||
}
|
||||
} label: {
|
||||
Text(filter.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.agentRosterFilter == filter ? .primary : .secondary)
|
||||
.padding(.horizontal, 15)
|
||||
.frame(height: AgentLayout.filterHeight)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.agentRosterFilter == filter
|
||||
? Color.primary.opacity(0.13)
|
||||
: Color.primary.opacity(0.055))
|
||||
}
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder(Color.primary.opacity(self.agentRosterFilter == filter ? 0.22 : 0.06))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if self.agentFiltersActive {
|
||||
self.headerIconButton(
|
||||
systemName: "xmark",
|
||||
label: "Clear filters",
|
||||
action: {
|
||||
self.agentRosterFilter = .all
|
||||
self.agentSearchText = ""
|
||||
})
|
||||
.frame(width: AgentLayout.filterHeight, height: AgentLayout.filterHeight)
|
||||
Text(filter.title).tag(filter)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
if self.agentFiltersActive {
|
||||
Button {
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
self.agentRosterFilter = .all
|
||||
self.agentSearchText = ""
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Clear filters")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var agentFiltersActive: Bool {
|
||||
|
|
@ -246,68 +222,44 @@ extension AgentProTab {
|
|||
func agentRow(_ agent: AgentSummary) -> some View {
|
||||
let isActive = agent.id == self.activeAgentID
|
||||
let state = self.agentRosterState(for: agent)
|
||||
return HStack(alignment: .top, spacing: 12) {
|
||||
self.agentAvatar(agent, state: state)
|
||||
return Button {
|
||||
guard !isActive else { return }
|
||||
self.appModel.setSelectedAgentId(agent.id)
|
||||
} label: {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
self.agentAvatar(agent, state: state)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(self.agentName(for: agent))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(state.color)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(state.title)
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
.foregroundStyle(state.color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.agentName(for: agent))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Text(self.agentDetail(for: agent))
|
||||
.font(.caption)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.layoutPriority(1)
|
||||
|
||||
HStack(spacing: 0) {
|
||||
self.agentMetric(label: "Sessions", value: self.agentSessionSummary(agent))
|
||||
Divider()
|
||||
.frame(height: 24)
|
||||
.padding(.horizontal, 12)
|
||||
self.agentMetric(label: "Runtime", value: self.agentRuntimeSummary(agent))
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if isActive {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.frame(width: 24, height: 44)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.layoutPriority(1)
|
||||
|
||||
Button {
|
||||
self.appModel.setSelectedAgentId(agent.id)
|
||||
} label: {
|
||||
Image(systemName: isActive ? "checkmark" : "arrow.right")
|
||||
.font(.caption.weight(.bold))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(isActive ? OpenClawBrand.accent : .primary)
|
||||
.frame(width: AgentLayout.actionButtonSize, height: AgentLayout.actionButtonSize)
|
||||
.background {
|
||||
Circle()
|
||||
.fill(self.iconButtonFill)
|
||||
.overlay {
|
||||
Circle().strokeBorder(self.iconButtonStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(isActive ? "Default agent" : "Set default agent")
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.padding(.horizontal, 13)
|
||||
.frame(maxWidth: .infinity, minHeight: AgentLayout.rowMinHeight, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
self.appModel.setSelectedAgentId(agent.id)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 13)
|
||||
.frame(maxWidth: .infinity, minHeight: AgentLayout.rowMinHeight, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(self.agentAccessibilityLabel(agent, isActive: isActive, state: state))
|
||||
.accessibilityHint(isActive ? "Selected agent" : "Selects this agent")
|
||||
}
|
||||
|
||||
func headerIconButton(
|
||||
|
|
@ -319,15 +271,9 @@ extension AgentProTab {
|
|||
Image(systemName: systemName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(width: AgentLayout.filterHeight, height: AgentLayout.filterHeight)
|
||||
.background {
|
||||
Circle()
|
||||
.fill(self.iconButtonFill)
|
||||
.overlay {
|
||||
Circle().strokeBorder(self.iconButtonStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonBorderShape(.circle)
|
||||
.openClawGlassButton()
|
||||
.accessibilityLabel(label)
|
||||
}
|
||||
|
||||
|
|
@ -338,40 +284,19 @@ extension AgentProTab {
|
|||
.foregroundStyle(.white)
|
||||
.minimumScaleFactor(0.62)
|
||||
.lineLimit(1)
|
||||
.frame(width: 48, height: 48)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
self.agentTint(for: agent, state: state),
|
||||
Color.primary.opacity(0.38),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)))
|
||||
.fill(self.agentTint(for: agent, state: state).gradient))
|
||||
.overlay(Circle().strokeBorder(Color.white.opacity(0.18), lineWidth: 1))
|
||||
|
||||
Circle()
|
||||
.fill(state.color)
|
||||
.frame(width: 10, height: 10)
|
||||
.overlay(Circle().strokeBorder(Color.primary.opacity(0.15), lineWidth: 1))
|
||||
.frame(width: 9, height: 9)
|
||||
.overlay(Circle().strokeBorder(Color(uiColor: .systemBackground), lineWidth: 2))
|
||||
}
|
||||
}
|
||||
|
||||
func agentMetric(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.74)
|
||||
}
|
||||
.frame(minWidth: 60, alignment: .leading)
|
||||
}
|
||||
|
||||
func agentMenuRow(
|
||||
icon: String,
|
||||
title: String,
|
||||
|
|
@ -562,22 +487,6 @@ extension AgentProTab {
|
|||
self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var searchFieldFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.78)
|
||||
}
|
||||
|
||||
private var searchFieldStroke: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.11) : Color.black.opacity(0.07)
|
||||
}
|
||||
|
||||
private var iconButtonFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.white.opacity(0.78)
|
||||
}
|
||||
|
||||
private var iconButtonStroke: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.14) : Color.black.opacity(0.07)
|
||||
}
|
||||
|
||||
var emptyAgentsTitle: String {
|
||||
if !self.gatewayConnected { return "Agents unavailable" }
|
||||
if !self.agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "No matches" }
|
||||
|
|
@ -600,7 +509,6 @@ extension AgentProTab {
|
|||
self.appModel.isOperatorGatewayConnected ? "operator" : "no-operator",
|
||||
self.activeAgentID,
|
||||
self.scenePhase == .active ? "active" : "inactive",
|
||||
"\(self.overviewRefreshNonce)",
|
||||
].joined(separator: ":")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import SwiftUI
|
|||
|
||||
struct AgentProTab: View {
|
||||
@Environment(NodeAppModel.self) var appModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
let directRoute: AgentRoute?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
|
|
@ -13,7 +12,6 @@ struct AgentProTab: View {
|
|||
@State var overview: AgentOverviewSnapshot?
|
||||
@State var overviewErrorText: String?
|
||||
@State var overviewLoading: Bool = false
|
||||
@State var overviewRefreshNonce: Int = 0
|
||||
@State var agentRosterFilter: AgentRosterFilter = .all
|
||||
@State var agentSearchPresented = false
|
||||
@State var agentSearchText = ""
|
||||
|
|
@ -84,24 +82,16 @@ struct AgentProTab: View {
|
|||
}
|
||||
|
||||
enum AgentLayout {
|
||||
static let cardRadius: CGFloat = 12
|
||||
static let cardRadius: CGFloat = OpenClawProMetric.cardRadius
|
||||
static let filterHeight: CGFloat = 34
|
||||
static let rowMinHeight: CGFloat = 104
|
||||
static let rowMinHeight: CGFloat = 72
|
||||
static let metricTileHeight: CGFloat = 94
|
||||
static let actionButtonSize: CGFloat = 34
|
||||
}
|
||||
|
||||
enum AgentRosterState: Equatable {
|
||||
case online
|
||||
case ready
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .online: "Online"
|
||||
case .ready: "Ready"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .online: OpenClawBrand.ok
|
||||
|
|
@ -185,7 +175,7 @@ struct AgentProTab: View {
|
|||
private func directDestination(for route: AgentRoute) -> some View {
|
||||
self.destination(for: route)
|
||||
.toolbar(
|
||||
self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden,
|
||||
route == .agents || self.directHeaderLeadingAction(for: route) != nil ? .hidden : .visible,
|
||||
for: .navigationBar)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,16 +135,9 @@ struct ChatProTab: View {
|
|||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
OpenClawBrand.accent,
|
||||
OpenClawBrand.accentHot,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)))
|
||||
.fill(OpenClawBrand.accent.gradient))
|
||||
.overlay(Circle().strokeBorder(.white.opacity(0.18), lineWidth: 1))
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: 10, y: 5)
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.14), radius: 5, y: 2)
|
||||
} else {
|
||||
ProIconBadge(systemName: "bubble.left", color: OpenClawBrand.accent)
|
||||
}
|
||||
|
|
@ -208,7 +201,8 @@ struct ChatProTab: View {
|
|||
Button(action: openSettings) {
|
||||
self.connectionPill
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonBorderShape(.capsule)
|
||||
.openClawGlassButton()
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
self.connectionPill
|
||||
|
|
@ -223,16 +217,8 @@ struct ChatProTab: View {
|
|||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(self.gatewayPillColor)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.horizontal, 4)
|
||||
.frame(height: 30)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.gatewayPillColor.opacity(0.11))
|
||||
}
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder(self.gatewayPillColor.opacity(0.16), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
|
|
@ -281,8 +267,8 @@ struct ChatProTab: View {
|
|||
?? Self.defaultHeaderTitle(showsAgentBadge: self.showsAgentBadge, agentDisplayName: self.agentDisplayName)
|
||||
}
|
||||
|
||||
private var headerDisplaySubtitle: String {
|
||||
self.normalized(self.headerSubtitle) ?? "AI Assistant"
|
||||
private var headerDisplaySubtitle: String? {
|
||||
self.normalized(self.headerSubtitle)
|
||||
}
|
||||
|
||||
nonisolated static func defaultHeaderTitle(showsAgentBadge: Bool, agentDisplayName: String) -> String {
|
||||
|
|
|
|||
|
|
@ -31,22 +31,12 @@ struct CommandPanel<Content: View>: View {
|
|||
}
|
||||
|
||||
struct CommandControlBackground: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground)
|
||||
.overlay(alignment: .top) {
|
||||
if self.colorScheme == .light {
|
||||
Color.white.opacity(0.20)
|
||||
.frame(height: 140)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
OpenClawProBackground()
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandSessionRow: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let item: CommandCenterTab.WorkItem
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -88,16 +78,9 @@ struct CommandSessionRow: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.fill(self.rowFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.strokeBorder(self.rowBorder, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var progressLabel: String {
|
||||
|
|
@ -109,41 +92,16 @@ struct CommandSessionRow: View {
|
|||
}
|
||||
return "\(Int((progress * 100).rounded()))%"
|
||||
}
|
||||
|
||||
private var rowFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground)
|
||||
}
|
||||
|
||||
private var rowBorder: Color {
|
||||
Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandViewMoreRow: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Text("View More")
|
||||
Label("View More", systemImage: "chevron.right")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.fill(self.rowFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.strokeBorder(self.rowBorder, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rowFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground)
|
||||
}
|
||||
|
||||
private var rowBorder: Color {
|
||||
Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -173,15 +131,7 @@ struct CommandEmptyStateRow: View {
|
|||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.fill(Color(uiColor: .systemBackground))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.strokeBorder(Color(uiColor: .separator).opacity(0.22), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,14 +119,13 @@ struct CommandCenterTab: View {
|
|||
}
|
||||
} accessory: {
|
||||
Button(action: self.openSettings) {
|
||||
ProCapsule(
|
||||
title: self.gatewayStateText,
|
||||
color: self.gatewayStatusColor,
|
||||
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash")
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(width: OpenClawProMetric.compactControlSize, height: OpenClawProMetric.compactControlSize)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Gateway \(self.gatewayStateText)")
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
.openClawGlassButton()
|
||||
.accessibilityLabel("Gateway settings")
|
||||
.accessibilityHint("Opens gateway settings")
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
|
@ -150,17 +149,13 @@ struct CommandCenterTab: View {
|
|||
private var gatewayCard: some View {
|
||||
CommandPanel(isProminent: true, padding: 12) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.cardHeader(
|
||||
title: "Gateway",
|
||||
value: self.gatewayStateText,
|
||||
color: self.gatewayStatusColor,
|
||||
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash")
|
||||
self.cardHeader(title: "Gateway")
|
||||
|
||||
HStack(spacing: 0) {
|
||||
self.gatewayFact(
|
||||
icon: "network",
|
||||
title: "Connection",
|
||||
value: self.gatewayConnected ? "Online" : "Offline",
|
||||
value: self.gatewayConnectionText,
|
||||
color: self.gatewayStatusColor)
|
||||
Divider().frame(height: 38)
|
||||
self.gatewayFact(
|
||||
|
|
@ -175,17 +170,7 @@ struct CommandCenterTab: View {
|
|||
value: self.gatewayAgentCountText,
|
||||
color: OpenClawBrand.accentHot)
|
||||
}
|
||||
.padding(.vertical, 9)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.colorScheme == .dark ? Color.black.opacity(0.16) : Color.black.opacity(0.026))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(
|
||||
Color.primary.opacity(self.colorScheme == .dark ? 0.08 : 0.045),
|
||||
lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 7)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
|
@ -215,10 +200,7 @@ struct CommandCenterTab: View {
|
|||
private var defaultChatSessionSection: some View {
|
||||
CommandPanel(padding: 12) {
|
||||
VStack(spacing: 10) {
|
||||
self.cardHeader(
|
||||
title: "Agent session",
|
||||
value: nil,
|
||||
color: OpenClawBrand.accent)
|
||||
self.cardHeader(title: "Agent session")
|
||||
|
||||
Button {
|
||||
self.open(.chat(nil))
|
||||
|
|
@ -233,10 +215,7 @@ struct CommandCenterTab: View {
|
|||
private var recentSessions: some View {
|
||||
CommandPanel(padding: 12) {
|
||||
VStack(spacing: 10) {
|
||||
self.cardHeader(
|
||||
title: "Recent sessions",
|
||||
value: nil,
|
||||
color: .secondary)
|
||||
self.cardHeader(title: "Recent sessions")
|
||||
|
||||
if self.recentSessionPreviewRows.isEmpty {
|
||||
CommandEmptyStateRow(
|
||||
|
|
@ -276,64 +255,47 @@ struct CommandCenterTab: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func cardHeader(
|
||||
title: String,
|
||||
value: String?,
|
||||
color: Color,
|
||||
icon: String? = nil,
|
||||
badgeValue: String? = nil,
|
||||
action: (() -> Void)? = nil) -> some View
|
||||
{
|
||||
private func cardHeader(title: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
if let badgeValue {
|
||||
Text(badgeValue)
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(OpenClawBrand.accentHot, in: Capsule())
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if let value {
|
||||
if let action {
|
||||
Button(value, action: action)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
} else {
|
||||
HStack(spacing: 4) {
|
||||
if let icon {
|
||||
Image(systemName: icon)
|
||||
.font(.caption2.weight(.bold))
|
||||
}
|
||||
Text(value)
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
self.gatewayDisplayState == .connected
|
||||
}
|
||||
|
||||
private var gatewayStateText: String {
|
||||
guard !self.gatewayConnected else { return "Healthy" }
|
||||
let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lowercased = status.lowercased()
|
||||
if lowercased.contains("approval") { return "Approval" }
|
||||
if lowercased.contains("reconnect") { return "Reconnecting" }
|
||||
if lowercased.contains("connect") { return "Connecting" }
|
||||
if lowercased.contains("idle") { return "Idle" }
|
||||
return "Offline"
|
||||
private var gatewayDisplayState: GatewayDisplayState {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel)
|
||||
}
|
||||
|
||||
private var gatewayConnectionText: String {
|
||||
switch self.gatewayDisplayState {
|
||||
case .connected:
|
||||
"Online"
|
||||
case .connecting:
|
||||
"Connecting"
|
||||
case .error:
|
||||
"Attention"
|
||||
case .disconnected:
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayStatusColor: Color {
|
||||
self.gatewayConnected ? OpenClawBrand.ok : .secondary
|
||||
switch self.gatewayDisplayState {
|
||||
case .connected:
|
||||
OpenClawBrand.ok
|
||||
case .connecting:
|
||||
OpenClawBrand.accent
|
||||
case .error:
|
||||
OpenClawBrand.warn
|
||||
case .disconnected:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayAddressText: String {
|
||||
|
|
|
|||
|
|
@ -58,7 +58,8 @@ struct IPadSidebarScreenChrome<Content: View>: View {
|
|||
Button(action: gatewayAction) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonBorderShape(.capsule)
|
||||
.openClawGlassButton()
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@ struct OpenClawDocsScreen: View {
|
|||
Button(action: gatewayAction) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonBorderShape(.capsule)
|
||||
.openClawGlassButton()
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
|
|
|
|||
|
|
@ -1,25 +1,17 @@
|
|||
import SwiftUI
|
||||
|
||||
enum OpenClawProMetric {
|
||||
static let pagePadding: CGFloat = 18
|
||||
static let cardRadius: CGFloat = 10
|
||||
static let controlRadius: CGFloat = 8
|
||||
static let pagePadding: CGFloat = 16
|
||||
static let cardRadius: CGFloat = 16
|
||||
static let controlRadius: CGFloat = 12
|
||||
static let compactControlSize: CGFloat = 36
|
||||
static let bottomScrollInset: CGFloat = 96
|
||||
}
|
||||
|
||||
struct OpenClawProBackground: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground)
|
||||
Color(uiColor: .systemGroupedBackground)
|
||||
.ignoresSafeArea()
|
||||
.overlay(alignment: .top) {
|
||||
if self.colorScheme == .light {
|
||||
Color.white.opacity(0.22)
|
||||
.frame(height: 140)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -83,76 +75,87 @@ private struct ProPanelBackground: View {
|
|||
.overlay {
|
||||
shape.strokeBorder(self.borderStyle, lineWidth: 1)
|
||||
}
|
||||
.overlay {
|
||||
if self.isProminent {
|
||||
shape.strokeBorder(
|
||||
OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.12 : 0.07),
|
||||
lineWidth: 1)
|
||||
.padding(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var fill: AnyShapeStyle {
|
||||
let base = self.isProminent
|
||||
? Color(uiColor: .systemBackground)
|
||||
: Color(uiColor: .secondarySystemGroupedBackground)
|
||||
if let tint {
|
||||
let gradient = LinearGradient(
|
||||
colors: [
|
||||
base,
|
||||
tint.opacity(self.colorScheme == .dark ? 0.08 : 0.045),
|
||||
base,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
return AnyShapeStyle(gradient)
|
||||
}
|
||||
return AnyShapeStyle(base)
|
||||
let color = self.isProminent ? UIColor.systemBackground : UIColor.secondarySystemGroupedBackground
|
||||
return AnyShapeStyle(Color(uiColor: color))
|
||||
}
|
||||
|
||||
private var borderStyle: AnyShapeStyle {
|
||||
AnyShapeStyle(Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.26 : 0.30))
|
||||
if let tint {
|
||||
return AnyShapeStyle(tint.opacity(self.isProminent ? 0.18 : 0.10))
|
||||
}
|
||||
return AnyShapeStyle(Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.22 : 0.12))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProLightGlassModifier: ViewModifier {
|
||||
private struct ProInsetSurfaceModifier: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let tint: Color
|
||||
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))
|
||||
let shape = RoundedRectangle(cornerRadius: self.radius, style: .continuous)
|
||||
content.background {
|
||||
shape
|
||||
.fill(Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.overlay {
|
||||
shape.strokeBorder(
|
||||
self.tint.opacity(self.colorScheme == .dark ? 0.18 : 0.10),
|
||||
lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OpenClawGlassButtonModifier: ViewModifier {
|
||||
let prominent: Bool
|
||||
let tint: Color?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
if self.prominent {
|
||||
content
|
||||
.buttonStyle(.glassProminent)
|
||||
.tint(self.tint ?? OpenClawBrand.accent)
|
||||
} else {
|
||||
content
|
||||
.buttonStyle(.glass)
|
||||
.tint(self.tint)
|
||||
}
|
||||
} else if self.prominent {
|
||||
content
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(self.tint ?? OpenClawBrand.accent)
|
||||
} else {
|
||||
content
|
||||
.buttonStyle(.bordered)
|
||||
.tint(self.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OpenClawTabBarBehaviorModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
content.tabBarMinimizeBehavior(.onScrollDown)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProGlassSurfaceModifier: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let fill: Color
|
||||
let stroke: Color
|
||||
private struct OpenClawGlassSurfaceModifier: ViewModifier {
|
||||
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))
|
||||
if #available(iOS 26.0, *) {
|
||||
content.glassEffect(.regular, in: .rect(cornerRadius: self.radius))
|
||||
} else {
|
||||
surfaced
|
||||
content.background(
|
||||
.regularMaterial,
|
||||
in: RoundedRectangle(cornerRadius: self.radius, style: .continuous))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -169,19 +172,20 @@ extension View {
|
|||
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))
|
||||
func proInsetSurface(tint: Color, radius: CGFloat) -> some View {
|
||||
self.modifier(ProInsetSurfaceModifier(tint: tint, radius: radius))
|
||||
}
|
||||
|
||||
func openClawGlassButton(prominent: Bool = false, tint: Color? = nil) -> some View {
|
||||
self.modifier(OpenClawGlassButtonModifier(prominent: prominent, tint: tint))
|
||||
}
|
||||
|
||||
func openClawTabBarBehavior() -> some View {
|
||||
self.modifier(OpenClawTabBarBehaviorModifier())
|
||||
}
|
||||
|
||||
func openClawGlassSurface(radius: CGFloat = OpenClawProMetric.controlRadius) -> some View {
|
||||
self.modifier(OpenClawGlassSurfaceModifier(radius: radius))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -199,11 +203,12 @@ private struct ProPanelSurfaceModifier: ViewModifier {
|
|||
tint: self.tint,
|
||||
isProminent: self.isProminent)
|
||||
}
|
||||
.modifier(ProLightGlassModifier(radius: self.radius))
|
||||
.shadow(
|
||||
color: self.colorScheme == .dark ? .black.opacity(0.22) : .black.opacity(0.028),
|
||||
radius: self.isProminent ? 9 : 4,
|
||||
y: self.isProminent ? 4 : 1)
|
||||
color: self.isProminent
|
||||
? (self.colorScheme == .dark ? .black.opacity(0.14) : .black.opacity(0.045))
|
||||
: .clear,
|
||||
radius: self.isProminent ? 5 : 0,
|
||||
y: self.isProminent ? 2 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -253,11 +258,13 @@ struct OpenClawSidebarRevealButton: View {
|
|||
let button = Button(action: self.headerAction.action) {
|
||||
Image(systemName: self.headerAction.systemName)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.frame(width: 38, height: 38)
|
||||
.frame(
|
||||
width: OpenClawProMetric.compactControlSize,
|
||||
height: OpenClawProMetric.compactControlSize)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.buttonBorderShape(.circle)
|
||||
.openClawGlassButton(tint: OpenClawBrand.accent)
|
||||
.accessibilityLabel(self.headerAction.accessibilityLabel)
|
||||
|
||||
if let accessibilityIdentifier = self.headerAction.accessibilityIdentifier {
|
||||
|
|
@ -277,9 +284,105 @@ struct OpenClawSidebarHeaderLeadingSlot: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct OpenClawGlassControlGroup<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
GlassEffectContainer(spacing: 8) {
|
||||
self.content
|
||||
}
|
||||
} else {
|
||||
self.content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum OpenClawNoticeDetail {
|
||||
case accent(String)
|
||||
case requestID(String)
|
||||
}
|
||||
|
||||
struct OpenClawNoticeBanner: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
let ownerLabel: String
|
||||
let tint: Color
|
||||
var detail: OpenClawNoticeDetail?
|
||||
var primaryActionTitle: String?
|
||||
var onPrimaryAction: (() -> Void)?
|
||||
var secondaryActionTitle: String?
|
||||
var onSecondaryAction: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
ProCard(tint: self.tint, padding: 14) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.icon, color: self.tint)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer(minLength: 0)
|
||||
Text(self.ownerLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(self.message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.detailView
|
||||
}
|
||||
}
|
||||
|
||||
if self.onPrimaryAction != nil || self.onSecondaryAction != nil {
|
||||
OpenClawGlassControlGroup {
|
||||
HStack(spacing: 10) {
|
||||
if let primaryActionTitle, let onPrimaryAction {
|
||||
Button(primaryActionTitle, action: onPrimaryAction)
|
||||
.openClawGlassButton(prominent: true)
|
||||
.controlSize(.small)
|
||||
}
|
||||
if let secondaryActionTitle, let onSecondaryAction {
|
||||
Button(secondaryActionTitle, action: onSecondaryAction)
|
||||
.openClawGlassButton()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var detailView: some View {
|
||||
if let detail {
|
||||
switch detail {
|
||||
case let .accent(value):
|
||||
Text(value)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(self.tint)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
case let .requestID(value):
|
||||
Text("Request ID: \(value)")
|
||||
.font(.system(.caption, design: .monospaced).weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let subtitle: String?
|
||||
var titleFont: Font = .title3.weight(.semibold)
|
||||
var subtitleFont: Font = .subheadline
|
||||
var subtitleLineLimit: Int? = 2
|
||||
|
|
@ -288,7 +391,7 @@ struct OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View {
|
|||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
subtitle: String? = nil,
|
||||
titleFont: Font = .title3.weight(.semibold),
|
||||
subtitleFont: Font = .subheadline,
|
||||
subtitleLineLimit: Int? = 2,
|
||||
|
|
@ -351,11 +454,13 @@ struct OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View {
|
|||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.86)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(self.subtitle)
|
||||
.font(self.subtitleFont)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(self.subtitleLineLimit)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.font(self.subtitleFont)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(self.subtitleLineLimit)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -422,47 +527,22 @@ struct ProProgressBar: View {
|
|||
}
|
||||
}
|
||||
|
||||
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))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.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 OpenClawGatewayCompactPill: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
|
||||
var body: some View {
|
||||
ProCapsule(
|
||||
title: self.title,
|
||||
color: self.color,
|
||||
icon: self.icon)
|
||||
.accessibilityLabel("Gateway \(self.title)")
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: self.icon)
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(self.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(self.color)
|
||||
.padding(.horizontal, 4)
|
||||
.frame(minHeight: 30)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.accessibilityLabel("Gateway \(self.title)")
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
|
|
@ -536,10 +616,7 @@ struct ProMetricTile: View {
|
|||
}
|
||||
.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)
|
||||
.proInsetSurface(tint: self.color, radius: OpenClawProMetric.controlRadius)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ struct RootTabsPhoneControlHub: View {
|
|||
.safeAreaPadding(.bottom, self.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Control")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationDestination(for: RootTabs.SidebarDestination.self) { destination in
|
||||
self.detail(for: destination)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
|
|
@ -39,15 +39,19 @@ struct RootTabsPhoneControlHub: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var headerCard: some View {
|
||||
if self.isCompactHeight {
|
||||
ProCard(padding: 8, radius: OpenClawProMetric.cardRadius) {
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
Button {
|
||||
self.openPhoneRootDestination(.gateway)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
OpenClawProMark(size: 24, shadowRadius: 3)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
OpenClawProMark(
|
||||
size: self.isCompactHeight ? 28 : 34,
|
||||
shadowRadius: self.isCompactHeight ? 3 : 5)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.sidebarActiveAgentTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Text(self.gatewayDisplayLabel)
|
||||
.font(.footnote)
|
||||
|
|
@ -56,71 +60,29 @@ struct RootTabsPhoneControlHub: View {
|
|||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.gatewayStateText, color: self.gatewayStateColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
} else {
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
OpenClawProMark(size: 32, shadowRadius: 4)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.sidebarActiveAgentTitle)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Text(self.gatewayDisplayLabel)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.gatewayStateText, color: self.gatewayStateColor)
|
||||
HStack(spacing: 7) {
|
||||
ProStatusDot(color: self.gatewayStateColor)
|
||||
Text(self.gatewayStateText)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(self.gatewayStateColor)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
self.gatewayActionRow
|
||||
}
|
||||
.padding(self.isCompactHeight ? 10 : 14)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Gateway \(self.gatewayStateText)")
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayActionRow: some View {
|
||||
Button {
|
||||
self.openPhoneRootDestination(.gateway)
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
ProStatusDot(color: self.gatewayStateColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.gatewayStateText)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(self.gatewayDisplayLabel)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(self.gatewayActionTitle)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Gateway \(self.gatewayStateText)")
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func groupSection(_ group: RootTabs.SidebarGroup) -> some View {
|
||||
VStack(alignment: .leading, spacing: self.isCompactHeight ? 6 : 8) {
|
||||
ProSectionHeader(title: group.title.capitalized)
|
||||
ProSectionHeader(title: group.title.capitalized, uppercase: false)
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(group.destinations.enumerated()), id: \.element.id) { index, destination in
|
||||
|
|
@ -157,21 +119,15 @@ struct RootTabsPhoneControlHub: View {
|
|||
private func rowLabel(_ destination: RootTabs.SidebarDestination) -> some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
ProIconBadge(systemName: destination.systemImage, color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(destination.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(destination.subtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(destination.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, self.isCompactHeight ? 8 : 10)
|
||||
.padding(.vertical, self.isCompactHeight ? 7 : 9)
|
||||
.padding(.horizontal, 14)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
|
@ -310,19 +266,6 @@ struct RootTabsPhoneControlHub: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var gatewayActionTitle: String {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
"Manage"
|
||||
case .connecting:
|
||||
"Details"
|
||||
case .error:
|
||||
"Fix"
|
||||
case .disconnected:
|
||||
"Connect"
|
||||
}
|
||||
}
|
||||
|
||||
private var isCompactHeight: Bool {
|
||||
self.verticalSizeClass == .compact
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ extension SettingsProTab {
|
|||
var settingsHeader: some View {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Settings",
|
||||
subtitle: "Gateway, permissions, voice, and device controls.",
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .callout)
|
||||
{
|
||||
|
|
@ -40,8 +39,8 @@ extension SettingsProTab {
|
|||
}
|
||||
|
||||
var gatewaySection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Gateway", uppercase: false)
|
||||
// Keep this container boundary: copying a directly returned generic ProCard crashes SwiftUI on iOS 26.
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
NavigationLink(value: SettingsRoute.gateway) {
|
||||
|
|
@ -51,15 +50,6 @@ extension SettingsProTab {
|
|||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Divider()
|
||||
self.gatewayDetailRow(label: "Address", value: self.gatewayAddress)
|
||||
Divider()
|
||||
self.gatewayDetailRow(label: "Server", value: self.gatewayServer)
|
||||
Divider()
|
||||
self.gatewayDetailRow(label: "Agents", value: "\(self.appModel.gatewayAgents.count)")
|
||||
Divider()
|
||||
self.gatewayActions
|
||||
.padding(14)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
|
@ -73,11 +63,14 @@ extension SettingsProTab {
|
|||
color: self.gatewayStatusColor)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Connection")
|
||||
Text(self.gatewayServer)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(self.gatewayStatusDetail)
|
||||
.lineLimit(1)
|
||||
Text(self.gatewaySummaryDetail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.gatewayStatusColor)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
|
@ -88,20 +81,10 @@ extension SettingsProTab {
|
|||
}
|
||||
}
|
||||
|
||||
func gatewayDetailRow(label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text(value)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 40)
|
||||
var gatewaySummaryDetail: String {
|
||||
let agentCount = self.appModel.gatewayAgents.count
|
||||
let agents = agentCount == 1 ? "1 agent" : "\(agentCount) agents"
|
||||
return "\(self.gatewayStatusDetail) • \(agents)"
|
||||
}
|
||||
|
||||
var gatewayActions: some View {
|
||||
|
|
@ -128,51 +111,70 @@ extension SettingsProTab {
|
|||
}
|
||||
|
||||
var settingsListSection: some View {
|
||||
VStack(spacing: 10) {
|
||||
self.settingsListRow(
|
||||
icon: "checkmark.shield.fill",
|
||||
title: "Approvals",
|
||||
detail: self.approvalsDetail,
|
||||
route: .approvals,
|
||||
color: self.pendingApproval == nil ? .secondary : OpenClawBrand.warn,
|
||||
badgeValue: self.pendingApproval == nil ? nil : "1")
|
||||
self.settingsListRow(
|
||||
icon: "person.2",
|
||||
title: "Permissions",
|
||||
detail: self.permissionsDetail,
|
||||
route: .permissions)
|
||||
self.settingsListRow(
|
||||
icon: "point.3.connected.trianglepath.dotted",
|
||||
title: "Channels / Integrations",
|
||||
detail: "Message routing and external channel clients.",
|
||||
route: .channels)
|
||||
self.settingsListRow(
|
||||
icon: "waveform",
|
||||
title: "Voice & Talk",
|
||||
detail: self.voiceDetail,
|
||||
route: .voice)
|
||||
self.settingsListRow(
|
||||
icon: "globe",
|
||||
title: "Diagnostics",
|
||||
detail: self.diagnosticsDetail,
|
||||
route: .diagnostics)
|
||||
self.settingsListRow(
|
||||
icon: "hand.raised",
|
||||
title: "Privacy",
|
||||
detail: self.privacyDetail,
|
||||
route: .privacy)
|
||||
self.settingsListRow(
|
||||
icon: "bell",
|
||||
title: "Notifications",
|
||||
detail: self.notificationStatusText,
|
||||
route: .notifications)
|
||||
self.settingsListRow(
|
||||
icon: "info.circle",
|
||||
title: "About",
|
||||
detail: DeviceInfoHelper.openClawVersionString(),
|
||||
route: .about)
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
self.settingsListRow(
|
||||
icon: "checkmark.shield.fill",
|
||||
title: "Approvals",
|
||||
detail: self.approvalsDetail,
|
||||
route: .approvals,
|
||||
color: self.pendingApproval == nil ? .secondary : OpenClawBrand.warn,
|
||||
badgeValue: self.pendingApproval == nil ? nil : "1")
|
||||
Divider().padding(.leading, 58)
|
||||
self.settingsListRow(
|
||||
icon: "person.2",
|
||||
title: "Permissions",
|
||||
detail: self.permissionsDetail,
|
||||
route: .permissions)
|
||||
Divider().padding(.leading, 58)
|
||||
self.settingsListRow(
|
||||
icon: "point.3.connected.trianglepath.dotted",
|
||||
title: "Channels",
|
||||
detail: "Connected services and message routing",
|
||||
route: .channels)
|
||||
Divider().padding(.leading, 58)
|
||||
self.settingsListRow(
|
||||
icon: "waveform",
|
||||
title: "Voice & Talk",
|
||||
detail: self.voiceDetail,
|
||||
route: .voice)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Device", uppercase: false)
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
self.settingsListRow(
|
||||
icon: "globe",
|
||||
title: "Diagnostics",
|
||||
detail: self.diagnosticsDetail,
|
||||
route: .diagnostics)
|
||||
Divider().padding(.leading, 58)
|
||||
self.settingsListRow(
|
||||
icon: "hand.raised",
|
||||
title: "Privacy",
|
||||
detail: self.privacyDetail,
|
||||
route: .privacy)
|
||||
Divider().padding(.leading, 58)
|
||||
self.settingsListRow(
|
||||
icon: "bell",
|
||||
title: "Notifications",
|
||||
detail: self.notificationStatusText,
|
||||
route: .notifications)
|
||||
Divider().padding(.leading, 58)
|
||||
self.settingsListRow(
|
||||
icon: "info.circle",
|
||||
title: "About",
|
||||
detail: DeviceInfoHelper.openClawVersionString(),
|
||||
route: .about)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func settingsListRow(
|
||||
|
|
@ -202,9 +204,10 @@ extension SettingsProTab {
|
|||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 9)
|
||||
.frame(maxWidth: .infinity, minHeight: SettingsLayout.rowHeight, alignment: .leading)
|
||||
.proPanelSurface(radius: SettingsLayout.cardRadius)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
|
@ -612,15 +615,12 @@ extension SettingsProTab {
|
|||
.minimumScaleFactor(0.76)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 34)
|
||||
.foregroundStyle(color)
|
||||
.background(color.opacity(0.09), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.strokeBorder(color.opacity(0.14))
|
||||
}
|
||||
.frame(height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.roundedRectangle(radius: 8))
|
||||
.tint(color)
|
||||
.controlSize(.small)
|
||||
.disabled(isBusy || isDisabled)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ enum SettingsRoute: Hashable {
|
|||
}
|
||||
|
||||
enum SettingsLayout {
|
||||
static let cardRadius: CGFloat = 12
|
||||
static let cardRadius: CGFloat = OpenClawProMetric.cardRadius
|
||||
static let rowHeight: CGFloat = 58
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import SwiftUI
|
|||
struct TalkProTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage(TalkSpeechLocale.storageKey) private var talkSpeechLocale: String = TalkSpeechLocale.automaticID
|
||||
@AppStorage(TalkDefaults.speakerphoneEnabledKey) private var talkSpeakerphoneEnabled: Bool =
|
||||
TalkDefaults.speakerphoneEnabledByDefault
|
||||
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
|
||||
|
|
@ -85,7 +84,7 @@ struct TalkProTab: View {
|
|||
ZStack {
|
||||
CommandControlBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.header
|
||||
if let fallbackIssue = self.fallbackIssue {
|
||||
TalkRuntimeIssueBanner(
|
||||
|
|
@ -97,8 +96,6 @@ struct TalkProTab: View {
|
|||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
self.voiceHeroCard
|
||||
self.conversationCard
|
||||
self.voiceModeCard
|
||||
self.controlsCard
|
||||
}
|
||||
.padding(.top, 16)
|
||||
|
|
@ -109,54 +106,30 @@ struct TalkProTab: View {
|
|||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center, spacing: 11) {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Talk",
|
||||
subtitle: self.headerSubtitle,
|
||||
titleFont: .system(size: 30, weight: .bold),
|
||||
subtitleFont: .caption.weight(.medium),
|
||||
subtitleLineLimit: 1)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
OpenClawProMark(size: 31, shadowRadius: 9)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Talk")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
Text(self.headerSubtitle)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
self.statusChip
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var statusChip: some View {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(self.state.color)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(self.state.chipText)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(self.state.color)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background {
|
||||
Capsule(style: .continuous)
|
||||
.fill(self.state.color.opacity(0.11))
|
||||
.overlay {
|
||||
Capsule(style: .continuous)
|
||||
.strokeBorder(self.state.color.opacity(0.22), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var voiceHeroCard: some View {
|
||||
CommandPanel(tint: self.state.color, isProminent: true, padding: 16) {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
CommandPanel(isProminent: true, padding: 16) {
|
||||
VStack(alignment: .center, spacing: 14) {
|
||||
TalkProOrb(
|
||||
mode: self.state.waveformMode(micLevel: self.appModel.talkMode.micLevel),
|
||||
color: self.state.color,
|
||||
systemImage: self.state.icon)
|
||||
.frame(height: 188)
|
||||
.frame(height: 132)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(spacing: 5) {
|
||||
|
|
@ -172,84 +145,20 @@ struct TalkProTab: View {
|
|||
Button(action: self.handlePrimaryAction) {
|
||||
Label(self.state.primaryButtonTitle, systemImage: self.state.primaryButtonIcon)
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(self.state.primaryButtonFill)
|
||||
.shadow(color: self.state.primaryButtonFill.opacity(0.22), radius: 18, y: 8)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.buttonBorderShape(.capsule)
|
||||
.openClawGlassButton(prominent: true, tint: self.state.primaryButtonFill)
|
||||
.disabled(self.state.primaryAction == .waiting)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var conversationCard: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(title: "Conversation", value: self.state.chipText, color: self.state.color)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
self.infoRow(icon: "person.crop.circle.fill", title: "Agent", value: self.appModel.chatAgentName)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(
|
||||
icon: "bubble.left.and.text.bubble.right.fill",
|
||||
title: "Session",
|
||||
value: self.appModel.chatSessionKey)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: self.state.icon, title: "Runtime", value: self.appModel.talkMode.statusText)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var voiceModeCard: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(
|
||||
title: "Voice mode",
|
||||
value: "Settings ›",
|
||||
color: OpenClawBrand.accent,
|
||||
action: self.openVoiceSettings)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
self.infoRow(
|
||||
icon: "waveform",
|
||||
title: "Configured",
|
||||
value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(
|
||||
icon: "waveform",
|
||||
title: "Active now",
|
||||
value: self.activeModeText)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "antenna.radiowaves.left.and.right", title: "Transport", value: self.transportText)
|
||||
if let issueText = self.talkIssueText {
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "exclamationmark.triangle.fill", title: "Last issue", value: issueText)
|
||||
}
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "key.fill", title: "Permission", value: self.permissionText)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "globe", title: "Speech language", value: self.speechLocaleText)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var controlsCard: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(title: "Controls", value: nil, color: .secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
self.controlToggleRow("Speakerphone", isOn: self.talkSpeakerphoneBinding)
|
||||
Divider().padding(.leading, 14)
|
||||
self.controlToggleRow("Background listening", isOn: self.$talkBackgroundEnabled)
|
||||
|
|
@ -291,55 +200,6 @@ struct TalkProTab: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func cardHeader(
|
||||
title: String,
|
||||
value: String?,
|
||||
color: Color,
|
||||
action: (() -> Void)? = nil) -> some View
|
||||
{
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.bold))
|
||||
Spacer(minLength: 8)
|
||||
if let value {
|
||||
if let action {
|
||||
Button(value, action: action)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
} else {
|
||||
Text(value)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func infoRow(icon: String, title: String, value: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(self.state.color)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(self.state.color.opacity(0.11))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "—" : value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
!self.appModel.isAppleReviewDemoModeEnabled &&
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
|
|
@ -372,41 +232,6 @@ struct TalkProTab: View {
|
|||
return "Routes voice to \(self.appModel.chatAgentName)."
|
||||
}
|
||||
|
||||
private var transportText: String {
|
||||
let provider = self.appModel.talkMode.gatewayTalkProviderLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let transport = self.appModel.talkMode.gatewayTalkTransportLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if provider.isEmpty || provider == "Not loaded" { return transport.isEmpty ? "Not loaded" : transport }
|
||||
if transport.isEmpty || transport == "Not loaded" { return provider }
|
||||
return "\(provider) • \(transport)"
|
||||
}
|
||||
|
||||
private var activeModeText: String {
|
||||
let title = self.appModel.talkMode.gatewayTalkActiveModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let subtitle = (self.appModel.talkMode.gatewayTalkActiveModeSubtitle ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if title.isEmpty { return "Not active" }
|
||||
if subtitle.isEmpty { return title }
|
||||
return "\(title) • \(subtitle)"
|
||||
}
|
||||
|
||||
private var talkIssueText: String? {
|
||||
let text = (self.appModel.talkMode.gatewayTalkLastIssueText ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
private var permissionText: String {
|
||||
if let failure = self.appModel.talkMode.gatewayTalkPermissionState.failureMessage {
|
||||
return failure
|
||||
}
|
||||
return self.appModel.talkMode.gatewayTalkPermissionState.statusLabel
|
||||
}
|
||||
|
||||
private var speechLocaleText: String {
|
||||
if self.talkSpeechLocale == TalkSpeechLocale.automaticID { return "Automatic" }
|
||||
return self.talkSpeechLocale
|
||||
}
|
||||
|
||||
private func alignPersistedTalkState() {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled,
|
||||
self.talkEnabled || self.appModel.talkMode.isEnabled
|
||||
|
|
@ -579,7 +404,7 @@ struct TalkProState: Equatable {
|
|||
return OpenClawBrand.warn
|
||||
default:
|
||||
if !self.isConfigLoaded { return OpenClawBrand.warn }
|
||||
return self.isEnabled ? OpenClawBrand.ok : OpenClawBrand.accentHot
|
||||
return self.isEnabled ? OpenClawBrand.ok : .secondary
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -677,13 +502,13 @@ private struct TalkProOrb: View {
|
|||
}
|
||||
Circle()
|
||||
.fill(self.color.opacity(0.13))
|
||||
.frame(width: 128, height: 128)
|
||||
.frame(width: 104, height: 104)
|
||||
.overlay {
|
||||
Circle()
|
||||
.strokeBorder(self.color.opacity(0.30), lineWidth: 1)
|
||||
}
|
||||
TalkProWaveform(mode: self.mode, tint: self.color, barCount: 18)
|
||||
.frame(width: 116, height: 52)
|
||||
TalkProWaveform(mode: self.mode, tint: self.color, barCount: 12)
|
||||
.frame(width: 92, height: 44)
|
||||
.opacity(self.showsWaveform ? 1 : 0)
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.system(size: 34, weight: .bold))
|
||||
|
|
|
|||
|
|
@ -2,68 +2,22 @@ import SwiftUI
|
|||
import UIKit
|
||||
|
||||
struct TalkRuntimeIssueBanner: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let issue: TalkRuntimeIssue
|
||||
var onOpenSettings: (() -> Void)?
|
||||
var onShowDetails: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: self.iconName)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(self.tint)
|
||||
.frame(width: 20)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(self.issue.fallbackBannerTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer(minLength: 0)
|
||||
Text(self.issue.fallbackBannerOwnerLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(self.issue.fallbackBannerMessage)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(self.issue.displayMessage)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(self.tint)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
if let onOpenSettings {
|
||||
Button("Open Settings", action: onOpenSettings)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
if let onShowDetails {
|
||||
Button("Details", action: onShowDetails)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(13)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(.ultraThickMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(self.colorScheme == .dark ? 0.12 : 0.07), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(self.colorScheme == .dark ? 0.16 : 0.07), radius: 16, y: 7)
|
||||
}
|
||||
OpenClawNoticeBanner(
|
||||
icon: self.iconName,
|
||||
title: self.issue.fallbackBannerTitle,
|
||||
message: self.issue.fallbackBannerMessage,
|
||||
ownerLabel: self.issue.fallbackBannerOwnerLabel,
|
||||
tint: self.tint,
|
||||
detail: .accent(self.issue.displayMessage),
|
||||
primaryActionTitle: "Open Settings",
|
||||
onPrimaryAction: self.onOpenSettings,
|
||||
secondaryActionTitle: "Details",
|
||||
onSecondaryAction: self.onShowDetails)
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
|
|
|
|||
|
|
@ -3,71 +3,23 @@ import SwiftUI
|
|||
import UIKit
|
||||
|
||||
struct GatewayProblemBanner: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let problem: GatewayConnectionProblem
|
||||
var primaryActionTitle: String?
|
||||
var onPrimaryAction: (() -> Void)?
|
||||
var onShowDetails: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: self.iconName)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(self.tint)
|
||||
.frame(width: 20)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(self.problem.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer(minLength: 0)
|
||||
Text(self.ownerLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(self.problem.message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let requestId = self.problem.requestId {
|
||||
Text("Request ID: \(requestId)")
|
||||
.font(.system(.caption, design: .monospaced).weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
if let primaryActionTitle, let onPrimaryAction {
|
||||
Button(primaryActionTitle, action: onPrimaryAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
if let onShowDetails {
|
||||
Button("Details", action: onShowDetails)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(.ultraThickMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(self.colorScheme == .dark ? 0.12 : 0.07), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(self.colorScheme == .dark ? 0.18 : 0.08), radius: 18, y: 8)
|
||||
}
|
||||
OpenClawNoticeBanner(
|
||||
icon: self.iconName,
|
||||
title: self.problem.title,
|
||||
message: self.problem.message,
|
||||
ownerLabel: self.ownerLabel,
|
||||
tint: self.tint,
|
||||
detail: self.problem.requestId.map(OpenClawNoticeDetail.requestID),
|
||||
primaryActionTitle: self.primaryActionTitle,
|
||||
onPrimaryAction: self.onPrimaryAction,
|
||||
secondaryActionTitle: "Details",
|
||||
onSecondaryAction: self.onShowDetails)
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
|
|
|
|||
|
|
@ -188,6 +188,7 @@ struct RootTabs: View {
|
|||
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
||||
.tag(AppTab.settings)
|
||||
}
|
||||
.openClawTabBarBehavior()
|
||||
}
|
||||
|
||||
private var sidebarSplitContent: some View {
|
||||
|
|
@ -402,7 +403,6 @@ struct RootTabs: View {
|
|||
ChatProTab(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Chat",
|
||||
headerSubtitle: "Agent conversation",
|
||||
showsAgentBadge: false,
|
||||
ownsNavigationStack: false,
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
|
|
|
|||
|
|
@ -69,26 +69,6 @@ extension RootTabs {
|
|||
}
|
||||
}
|
||||
|
||||
var subtitle: String {
|
||||
switch self {
|
||||
case .chat: "Agent chat and recent work."
|
||||
case .talk: "Realtime voice and fallback controls."
|
||||
case .overview: "Status, entry points, health."
|
||||
case .activity: "Gateway, session, and device activity."
|
||||
case .agents: "Agent roster and readiness."
|
||||
case .workboard: "Agent work queue and session handoff."
|
||||
case .skillWorkshop: "Review and apply proposed skills."
|
||||
case .instances: "Latest presence from OpenClaw nodes."
|
||||
case .sessions: "Active sessions and defaults."
|
||||
case .dreaming: "Memory signals and background synthesis."
|
||||
case .usage: "API usage and costs."
|
||||
case .cron: "Wakeups and recurring runs."
|
||||
case .docs: "Reference docs and setup guides."
|
||||
case .settings: "Connection, permissions, channels, and app options."
|
||||
case .gateway: "Pairing, diagnostics, permissions, and device controls."
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .chat: "bubble.left"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import SwiftUI
|
||||
|
||||
struct VoiceWakeToast: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var command: String
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -19,10 +17,7 @@ struct VoiceWakeToast: View {
|
|||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 12)
|
||||
.proGlassSurface(
|
||||
fill: self.colorScheme == .dark ? Color.white.opacity(0.055) : Color.white.opacity(0.72),
|
||||
stroke: self.colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08),
|
||||
radius: 14)
|
||||
.openClawGlassSurface()
|
||||
.accessibilityLabel("Voice Wake triggered")
|
||||
.accessibilityValue("Command: \(self.command)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,7 +174,9 @@ struct RootTabsSourceGuardTests {
|
|||
let dreamingSource = try String(contentsOf: Self.agentProDreamingDestinationSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(!source.contains("ToolbarItem"))
|
||||
#expect(source.contains("self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden"))
|
||||
#expect(source
|
||||
.contains("route == .agents || self.directHeaderLeadingAction(for: route) != nil ? .hidden : .visible"))
|
||||
#expect(destinationsSource.contains(".toolbar(.hidden, for: .navigationBar)"))
|
||||
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .instances)"))
|
||||
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .dreaming)"))
|
||||
#expect(destinationsSource.contains("self.directHeader(\n for: .usage"))
|
||||
|
|
@ -184,6 +186,80 @@ struct RootTabsSourceGuardTests {
|
|||
#expect(dreamingSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
|
||||
}
|
||||
|
||||
@Test func `iOS 26 chrome uses native glass while content cards stay quiet`() throws {
|
||||
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
|
||||
let cardSurface = try Self.extract(
|
||||
componentsSource,
|
||||
from: "private struct ProPanelSurfaceModifier: ViewModifier",
|
||||
to: "struct ProIconBadge: View")
|
||||
|
||||
#expect(rootSource.contains(".openClawTabBarBehavior()"))
|
||||
#expect(componentsSource.contains("content.tabBarMinimizeBehavior(.onScrollDown)"))
|
||||
#expect(componentsSource.contains(".buttonStyle(.glassProminent)"))
|
||||
#expect(componentsSource.contains(".buttonStyle(.glass)"))
|
||||
#expect(componentsSource.contains("GlassEffectContainer(spacing: 8)"))
|
||||
#expect(componentsSource.contains("if #available(iOS 26.0, *)"))
|
||||
#expect(componentsSource.contains(".buttonStyle(.borderedProminent)"))
|
||||
#expect(componentsSource.contains(".buttonStyle(.bordered)"))
|
||||
#expect(componentsSource.contains("struct OpenClawNoticeBanner: View"))
|
||||
#expect(!cardSurface.contains("glassEffect"))
|
||||
}
|
||||
|
||||
@Test func `professional layout avoids nested pills and card stacks`() throws {
|
||||
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
|
||||
let agentSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
|
||||
let talkSource = try String(contentsOf: Self.talkProTabSourceURL(), encoding: .utf8)
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
let overviewSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8)
|
||||
let overviewRowsSource = try String(contentsOf: Self.commandCenterSupportSourceURL(), encoding: .utf8)
|
||||
let gatewayStatus = try Self.extract(
|
||||
componentsSource,
|
||||
from: "struct OpenClawGatewayCompactPill: View",
|
||||
to: "struct ProMetricTile: View")
|
||||
let agentFilters = try Self.extract(
|
||||
agentSource,
|
||||
from: "var agentFilters: some View",
|
||||
to: "var agentFiltersActive: Bool")
|
||||
let agentRow = try Self.extract(
|
||||
agentSource,
|
||||
from: "func agentRow(_ agent: AgentSummary) -> some View",
|
||||
to: "func headerIconButton(")
|
||||
let settingsList = try Self.extract(
|
||||
settingsSource,
|
||||
from: "var settingsListSection: some View",
|
||||
to: "func settingsListRow(")
|
||||
let settingsRow = try Self.extract(
|
||||
settingsSource,
|
||||
from: "func settingsListRow(",
|
||||
to: "func destination(for route:")
|
||||
|
||||
#expect(gatewayStatus.contains("HStack(spacing: 6)"))
|
||||
#expect(!gatewayStatus.contains("ProCapsule("))
|
||||
#expect(!gatewayStatus.contains("Capsule()"))
|
||||
#expect(agentFilters.contains("Picker(\"Agent status\""))
|
||||
#expect(agentFilters.contains(".pickerStyle(.segmented)"))
|
||||
#expect(!agentFilters.contains(".openClawGlassButton("))
|
||||
#expect(!agentRow.contains("agentMetric"))
|
||||
#expect(!agentRow.contains("chevron.right"))
|
||||
#expect(agentRow.contains("Image(systemName: \"checkmark\")"))
|
||||
#expect(agentRow.contains("agentAccessibilityLabel"))
|
||||
#expect(!talkSource.contains("conversationCard"))
|
||||
#expect(!talkSource.contains("voiceModeCard"))
|
||||
#expect(!talkSource.contains("statusChip"))
|
||||
#expect(!settingsList.contains("ProSectionHeader(title: \"OpenClaw\""))
|
||||
#expect(settingsList.contains("ProSectionHeader(title: \"Device\""))
|
||||
#expect(settingsList.matches(of: /ProCard\(padding: 0/).count == 2)
|
||||
#expect(settingsRow.contains(".contentShape(Rectangle())"))
|
||||
#expect(!overviewSource.contains("ProCapsule("))
|
||||
#expect(overviewSource.contains("value: self.gatewayConnectionText"))
|
||||
#expect(overviewSource.contains("switch self.gatewayDisplayState"))
|
||||
#expect(overviewSource.contains("case .connecting:"))
|
||||
#expect(overviewSource.contains("case .error:"))
|
||||
#expect(!overviewRowsSource.contains("private var rowFill"))
|
||||
#expect(overviewRowsSource.matches(of: /.contentShape\(Rectangle\(\)\)/).count >= 2)
|
||||
}
|
||||
|
||||
@Test func `routed headers use shared adaptive layout`() throws {
|
||||
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
|
||||
let featureChromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
|
||||
|
|
@ -257,7 +333,11 @@ struct RootTabsSourceGuardTests {
|
|||
@Test func `phone hub header stays task first`() throws {
|
||||
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("private var gatewayActionRow: some View"))
|
||||
#expect(source.contains("private var headerCard: some View"))
|
||||
#expect(source.contains(".accessibilityLabel(\"Gateway \\(self.gatewayStateText)\")"))
|
||||
#expect(!source.contains("private var gatewayActionRow: some View"))
|
||||
#expect(!source.contains("ProValuePill(value: self.gatewayStateText"))
|
||||
#expect(!source.contains("destination.subtitle"))
|
||||
#expect(source.contains("self.openPhoneRootDestination(.gateway)"))
|
||||
#expect(source.contains("private var phoneDetailBackAction: OpenClawSidebarHeaderAction"))
|
||||
#expect(source.contains("accessibilityLabel: \"Back to Control\""))
|
||||
|
|
@ -272,7 +352,7 @@ struct RootTabsSourceGuardTests {
|
|||
#expect(!source.contains("private func metric(label:"))
|
||||
}
|
||||
|
||||
@Test func phoneHubClearsDetailPathBeforeRootTabHandoff() throws {
|
||||
@Test func `phone hub clears detail path before root tab handoff`() throws {
|
||||
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
|
||||
let handoff = try Self.extract(
|
||||
source,
|
||||
|
|
@ -287,7 +367,7 @@ struct RootTabsSourceGuardTests {
|
|||
#expect(clearRange.lowerBound < openRange.lowerBound)
|
||||
}
|
||||
|
||||
@Test func workboardUsesRealGatewayMethods() throws {
|
||||
@Test func `workboard uses real gateway methods`() throws {
|
||||
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("workboard.cards.list"))
|
||||
|
|
@ -514,6 +594,8 @@ struct RootTabsSourceGuardTests {
|
|||
#expect(chromeSource.contains("let gatewayAction: (() -> Void)?"))
|
||||
#expect(chromeSource.contains("private var gatewayPill: some View"))
|
||||
#expect(chromeSource.contains("Button(action: gatewayAction)"))
|
||||
#expect(chromeSource.contains(".buttonBorderShape(.capsule)"))
|
||||
#expect(chromeSource.contains(".openClawGlassButton()"))
|
||||
#expect(chromeSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
#expect(featureSource.matches(of: /gatewayAction: self\.openSettings/).count == 2)
|
||||
#expect(rootSource.contains("IPadActivityScreen("))
|
||||
|
|
@ -541,7 +623,7 @@ struct RootTabsSourceGuardTests {
|
|||
#expect(!rootSource.contains("showGatewayActions"))
|
||||
#expect(!rootSource.contains("gatewayActionsDialog"))
|
||||
#expect(overviewSource.contains("Button(action: self.openSettings)"))
|
||||
#expect(overviewSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
#expect(overviewSource.contains(".accessibilityHint(\"Opens gateway settings\")"))
|
||||
#expect(agentSource.contains("let openSettings: (() -> Void)?"))
|
||||
#expect(agentOverviewSource.contains("OpenClawGatewayCompactPill()"))
|
||||
#expect(agentOverviewSource.contains("Button(action: openSettings)"))
|
||||
|
|
@ -550,7 +632,11 @@ struct RootTabsSourceGuardTests {
|
|||
.count >= 3)
|
||||
#expect(chatSource.contains("let openSettings: (() -> Void)?"))
|
||||
#expect(chatSource.contains("private var connectionPillButton: some View"))
|
||||
#expect(chatSource.contains(".buttonBorderShape(.capsule)"))
|
||||
#expect(chatSource.contains(".openClawGlassButton()"))
|
||||
#expect(docsSource.contains("let gatewayAction: (() -> Void)?"))
|
||||
#expect(docsSource.contains(".buttonBorderShape(.capsule)"))
|
||||
#expect(docsSource.contains(".openClawGlassButton()"))
|
||||
#expect(settingsSource.contains("NavigationLink(value: SettingsRoute.gateway)"))
|
||||
#expect(rootSource.contains("case .settings:"))
|
||||
#expect(rootSource
|
||||
|
|
@ -589,7 +675,7 @@ struct RootTabsSourceGuardTests {
|
|||
#expect(notificationGuidanceSource.contains("suppressFuture: true"))
|
||||
#expect(notificationGuidanceSource.contains("Text(\"Don't show again\")"))
|
||||
#expect(rootSource.contains("private func selectSettingsRoute(_ route: SettingsRoute)"))
|
||||
#expect(settingsSource.contains("title: \"Channels / Integrations\""))
|
||||
#expect(settingsSource.contains("title: \"Channels\""))
|
||||
#expect(settingsSource.contains("route: .channels"))
|
||||
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
}
|
||||
|
|
@ -638,7 +724,8 @@ struct RootTabsSourceGuardTests {
|
|||
#expect(actionsSource.contains("self.gatewayController.refreshActiveGatewayRegistrationFromSettings()"))
|
||||
#expect(actionsSource.contains("self.gatewayController.restartDiscovery()"))
|
||||
#expect(actionsSource.contains("await self.appModel.refreshGatewayOverviewIfConnected()"))
|
||||
#expect(actionsSource.contains("self.gatewayController.requestLocalNetworkAccess(reason: \"settings_preflight\")"))
|
||||
#expect(actionsSource
|
||||
.contains("self.gatewayController.requestLocalNetworkAccess(reason: \"settings_preflight\")"))
|
||||
#expect(controllerSource.contains("await self.tcpReachabilityProbe("))
|
||||
#expect(controllerSource.contains("Check Tailscale or LAN."))
|
||||
#expect(actionsSource.contains("Tailscale is off on this device. Turn it on, then try again."))
|
||||
|
|
@ -710,7 +797,7 @@ struct RootTabsSourceGuardTests {
|
|||
|
||||
#expect(chatSource.matches(of: /self\.appModel\.makeChatTransport\(\)/).count == 2)
|
||||
#expect(appModelSource.contains("return IOSGatewayChatTransport(gateway: self.operatorSession)"))
|
||||
#expect(settingsSectionsSource.contains("Message routing and external channel clients."))
|
||||
#expect(settingsSectionsSource.contains("Connected services and message routing"))
|
||||
#expect(channelsSource.contains("\"clickclack\": SettingsChannelFallbackMetadata"))
|
||||
#expect(channelsSource.contains("label: \"ClickClack\""))
|
||||
#expect(channelsSource.contains("Self-hosted chat bot routing."))
|
||||
|
|
@ -751,6 +838,13 @@ struct RootTabsSourceGuardTests {
|
|||
.appendingPathComponent("Sources/Design/CommandCenterTab.swift")
|
||||
}
|
||||
|
||||
private static func commandCenterSupportSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/CommandCenterSupport.swift")
|
||||
}
|
||||
|
||||
private static func agentProTabSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
|
|
@ -852,6 +946,13 @@ struct RootTabsSourceGuardTests {
|
|||
.appendingPathComponent("Sources/Design/ChatProTab.swift")
|
||||
}
|
||||
|
||||
private static func talkProTabSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/TalkProTab.swift")
|
||||
}
|
||||
|
||||
private static func docsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
|
|
|
|||
|
|
@ -30,15 +30,15 @@ final class OpenClawSnapshotUITests: XCTestCase {
|
|||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
app?.terminate()
|
||||
app = nil
|
||||
self.app?.terminate()
|
||||
self.app = nil
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
func testConnectedGatewayTabs() {
|
||||
for appearance in ScreenshotAppearance.allCases {
|
||||
for target in Self.screenshotTargets {
|
||||
launchApp(for: target, appearance: appearance)
|
||||
self.launchApp(for: target, appearance: appearance)
|
||||
let name = appearance == .light ? target.name : "\(target.name)-dark"
|
||||
snapshot(name, timeWaitingForIdle: 5)
|
||||
}
|
||||
|
|
@ -47,26 +47,50 @@ final class OpenClawSnapshotUITests: XCTestCase {
|
|||
|
||||
func testControlOverviewNavigation() throws {
|
||||
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone control hub only")
|
||||
launchApp(for: ScreenshotTarget(
|
||||
self.launchApp(for: ScreenshotTarget(
|
||||
initialTab: "control",
|
||||
initialDestination: "control",
|
||||
name: "control-overview-navigation"
|
||||
))
|
||||
name: "control-overview-navigation"))
|
||||
|
||||
let overview = app?.buttons.containing(.staticText, identifier: "Overview").firstMatch
|
||||
let overview = self.app?.buttons.containing(.staticText, identifier: "Overview").firstMatch
|
||||
XCTAssertTrue(overview?.waitForExistence(timeout: 5) == true)
|
||||
overview?.tap()
|
||||
|
||||
XCTAssertTrue(app?.buttons["Back to Control"].waitForExistence(timeout: 5) == true)
|
||||
XCTAssertEqual(app?.state, .runningForeground)
|
||||
XCTAssertTrue(self.app?.buttons["Back to Control"].waitForExistence(timeout: 5) == true)
|
||||
XCTAssertEqual(self.app?.state, .runningForeground)
|
||||
}
|
||||
|
||||
func testChatComposerStartsCompactAndGrowsWithDraft() throws {
|
||||
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone composer proof only")
|
||||
self.launchApp(for: ScreenshotTarget(
|
||||
initialTab: "chat",
|
||||
initialDestination: "chat",
|
||||
name: "chat-composer-growth"))
|
||||
|
||||
let textField = try XCTUnwrap(app?.textFields.firstMatch)
|
||||
XCTAssertTrue(textField.waitForExistence(timeout: 8))
|
||||
let compactHeight = textField.frame.height
|
||||
XCTAssertLessThanOrEqual(compactHeight, 44)
|
||||
self.attachScreenshot(named: "chat-composer-compact")
|
||||
|
||||
textField.tap()
|
||||
textField.typeText(
|
||||
"Draft a polished launch note that covers the new design, validation, rollout plan, and follow-up details for the team.")
|
||||
let composerGrew = expectation(
|
||||
for: NSPredicate { _, _ in textField.frame.height >= compactHeight + 12 },
|
||||
evaluatedWith: textField)
|
||||
wait(for: [composerGrew], timeout: 4)
|
||||
self.attachScreenshot(named: "chat-composer-expanded")
|
||||
|
||||
self.app?.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)).tap()
|
||||
XCTAssertTrue(self.app?.keyboards.firstMatch.waitForNonExistence(timeout: 3) == true)
|
||||
}
|
||||
|
||||
func testLiveGatewayControlOverviewNavigation() throws {
|
||||
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone control hub only")
|
||||
try XCTSkipUnless(
|
||||
ProcessInfo.processInfo.environment["OPENCLAW_IOS_LIVE_GATEWAY"] == "1",
|
||||
"Set OPENCLAW_IOS_LIVE_GATEWAY=1 and copy a fresh setup code to the simulator pasteboard"
|
||||
)
|
||||
"Set OPENCLAW_IOS_LIVE_GATEWAY=1 and copy a fresh setup code to the simulator pasteboard")
|
||||
|
||||
let app = XCUIApplication()
|
||||
addUIInterruptionMonitor(withDescription: "Local network access") { alert in
|
||||
|
|
@ -104,10 +128,10 @@ final class OpenClawSnapshotUITests: XCTestCase {
|
|||
|
||||
let overview = app.buttons.containing(.staticText, identifier: "Overview").firstMatch
|
||||
XCTAssertTrue(overview.waitForExistence(timeout: 8))
|
||||
attachScreenshot(named: "live-gateway-control")
|
||||
self.attachScreenshot(named: "live-gateway-control")
|
||||
overview.tap()
|
||||
XCTAssertTrue(app.buttons["Back to Control"].waitForExistence(timeout: 8))
|
||||
attachScreenshot(named: "live-gateway-overview")
|
||||
self.attachScreenshot(named: "live-gateway-overview")
|
||||
XCTAssertEqual(app.state, .runningForeground)
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +142,7 @@ final class OpenClawSnapshotUITests: XCTestCase {
|
|||
self.app?.terminate()
|
||||
|
||||
let app = XCUIApplication()
|
||||
setupSnapshot(app, waitForAnimations: false)
|
||||
setupSnapshot(app)
|
||||
app.launchArguments += [
|
||||
"--openclaw-screenshot-mode",
|
||||
"--openclaw-appearance",
|
||||
|
|
@ -137,7 +161,7 @@ final class OpenClawSnapshotUITests: XCTestCase {
|
|||
}
|
||||
|
||||
private func attachScreenshot(named name: String) {
|
||||
guard let app = app else { return }
|
||||
guard let app else { return }
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = name
|
||||
attachment.lifetime = .keepAlways
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ options:
|
|||
bundleIdPrefix: ai.openclawfoundation
|
||||
deploymentTarget:
|
||||
iOS: "18.0"
|
||||
xcodeVersion: "16.0"
|
||||
xcodeVersion: "26.0"
|
||||
|
||||
settings:
|
||||
base:
|
||||
|
|
|
|||
|
|
@ -333,11 +333,12 @@ struct OpenClawChatComposer: View {
|
|||
|
||||
private var cleanEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
self.compactAccessory(self.attachmentPicker)
|
||||
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
self.editorOverlay
|
||||
.padding(.vertical, self.cleanEditorTextPadding)
|
||||
.frame(minHeight: self.cleanEditorMinHeight)
|
||||
|
||||
if let talkControl {
|
||||
|
|
@ -348,10 +349,10 @@ struct OpenClawChatComposer: View {
|
|||
.padding(.trailing, 6)
|
||||
.frame(minHeight: self.cleanEditorMinHeight)
|
||||
.background(
|
||||
Capsule(style: .continuous)
|
||||
RoundedRectangle(cornerRadius: self.cleanEditorCornerRadius, style: .continuous)
|
||||
.fill(OpenClawChatTheme.composerField)
|
||||
.overlay(
|
||||
Capsule(style: .continuous)
|
||||
RoundedRectangle(cornerRadius: self.cleanEditorCornerRadius, style: .continuous)
|
||||
.strokeBorder(OpenClawChatTheme.composerBorder)))
|
||||
|
||||
self.sendButton
|
||||
|
|
@ -516,16 +517,13 @@ struct OpenClawChatComposer: View {
|
|||
text: self.$viewModel.input,
|
||||
axis: .vertical)
|
||||
.font(.body)
|
||||
.textFieldStyle(.plain)
|
||||
.lineLimit(1...4)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.submitLabel(.send)
|
||||
.onSubmit {
|
||||
self.sendDraftIfEnabled()
|
||||
}
|
||||
.frame(
|
||||
minHeight: self.textMinHeight,
|
||||
idealHeight: self.textMinHeight,
|
||||
maxHeight: self.textMaxHeight,
|
||||
alignment: self.editorTextAlignment)
|
||||
.padding(.horizontal, self.cleanFieldTextInset)
|
||||
.padding(.vertical, self.composerChrome == .clean ? 0 : 6)
|
||||
.focused(self.$isFocused)
|
||||
|
|
@ -637,6 +635,14 @@ struct OpenClawChatComposer: View {
|
|||
max(self.cleanControlHeight, self.textMinHeight)
|
||||
}
|
||||
|
||||
private var cleanEditorCornerRadius: CGFloat {
|
||||
self.cleanControlHeight / 2
|
||||
}
|
||||
|
||||
private var cleanEditorTextPadding: CGFloat {
|
||||
6
|
||||
}
|
||||
|
||||
private var sendButtonSize: CGFloat {
|
||||
self.composerChrome == .clean ? self.cleanControlHeight : 44
|
||||
}
|
||||
|
|
@ -661,10 +667,6 @@ struct OpenClawChatComposer: View {
|
|||
self.composerChrome == .clean ? .leading : .topLeading
|
||||
}
|
||||
|
||||
private var editorTextAlignment: Alignment {
|
||||
self.composerChrome == .clean ? .leading : .top
|
||||
}
|
||||
|
||||
private var sendButtonFill: Color {
|
||||
self.userAccent ?? OpenClawChatTheme.userBubble
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ struct ChatDynamicTypeSourceGuardTests {
|
|||
#expect(!sources.composer.contains(".frame(height: self.cleanControlHeight)"))
|
||||
#expect(!sources.composer.contains("return self.composerChrome == .clean ? 48 : 64"))
|
||||
#expect(sources.composer.contains("@ScaledMetric(relativeTo: .body)"))
|
||||
#expect(sources.composer.contains(".textFieldStyle(.plain)"))
|
||||
#expect(sources.composer.contains(".lineLimit(1...4)"))
|
||||
#expect(sources.composer.contains(".fixedSize(horizontal: false, vertical: true)"))
|
||||
}
|
||||
|
||||
private static func scopedChatTextSources() throws -> (
|
||||
|
|
|
|||
|
|
@ -123,7 +123,8 @@ const CONDITIONAL_BRANCHES = [
|
|||
/\bif\s*\([^)]*\)\s*"((?:\\.|[^"\\])*)"\s*else\s*"((?:\\.|[^"\\])*)"/gu,
|
||||
/\?\s*"((?:\\.|[^"\\])*)"\s*:\s*"((?:\\.|[^"\\])*)"/gu,
|
||||
];
|
||||
const UI_STRING_NAME_RE = /(?:title|subtitle|body|message|label|text|description|prompt|help)$/iu;
|
||||
const UI_STRING_NAME_RE =
|
||||
/(?:title|subtitle|body|message|label|text|description|detail|prompt|help)$/iu;
|
||||
const APPLE_STRING_PROPERTY = /\bvar\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*String\s*\{/gu;
|
||||
const APPLE_SWITCH_BRANCH =
|
||||
/(?:\bcase\b[^:\n]+|\bdefault)\s*:\s*(?:return\s+)?"((?:\\.|[^"\\])*)"/gu;
|
||||
|
|
@ -135,7 +136,7 @@ const ANDROID_RESOURCE_COLLECTIONS =
|
|||
/<(?:string-array|plurals)\b[^>]*>([\s\S]*?)<\/(?:string-array|plurals)>/gu;
|
||||
const ANDROID_RESOURCE_ITEMS = /<item\b[^>]*>([\s\S]*?)<\/item>/gu;
|
||||
const APPLE_NAMED_LITERALS =
|
||||
/\b(?:title|subtitle|label|message|text|prompt|description|help)\s*:\s*(?:"""([\s\S]*?)"""|"((?:\\.|[^"\\])*)")/gu;
|
||||
/\b([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(?:"""([\s\S]*?)"""|"((?:\\.|[^"\\])*)")/gu;
|
||||
const APPLE_VIEW_TYPE = /\bstruct\s+([A-Za-z_][A-Za-z0-9_]*)[^:{\n]*:\s*[^{\n]*\bView\b/gu;
|
||||
const APPLE_VIEW_FUNCTION =
|
||||
/\bfunc\s+([A-Za-z_][A-Za-z0-9_]*)\s*\([^{}]*?\)\s*(?:async\s*)?(?:throws\s*)?->\s*some\s+View\b/gu;
|
||||
|
|
@ -622,12 +623,18 @@ function extractCandidates(
|
|||
}
|
||||
}
|
||||
for (const match of source.matchAll(APPLE_NAMED_LITERALS)) {
|
||||
const argumentName = match[1];
|
||||
const callName = enclosingCallName(source, match.index ?? 0);
|
||||
if (!callName || !uiCallNames.has(callName)) {
|
||||
if (
|
||||
!argumentName ||
|
||||
!UI_STRING_NAME_RE.test(argumentName) ||
|
||||
!callName ||
|
||||
!uiCallNames.has(callName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const multiline = match[1];
|
||||
const literal = multiline ?? match[2];
|
||||
const multiline = match[2];
|
||||
const literal = multiline ?? match[3];
|
||||
if (literal) {
|
||||
addCandidate(
|
||||
entries,
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ describe("native app i18n inventory", () => {
|
|||
),
|
||||
).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "QR Scanner Unavailable")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Request ID: \\(requestId)")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Request ID: \\(value)")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Open ${row.title}")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "$deviceModel · $appVersion")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Approval command copied")).toBe(true);
|
||||
|
|
@ -54,8 +54,25 @@ describe("native app i18n inventory", () => {
|
|||
expect(entries.some((entry) => entry.source === "DIARY")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "ask OpenClaw $prompt")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "OpenClaw is paused")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Last issue")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Agent chat and recent work.")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Connected services and message routing")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
entries.some(
|
||||
(entry) =>
|
||||
entry.path === "apps/ios/Sources/Design/TalkRuntimeIssueBanner.swift" &&
|
||||
entry.kind === "ui-named-argument" &&
|
||||
entry.source === "Details",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
entries.some(
|
||||
(entry) =>
|
||||
entry.path === "apps/ios/Sources/Design/TalkRuntimeIssueBanner.swift" &&
|
||||
entry.kind === "ui-named-argument" &&
|
||||
entry.source === "Open Settings",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "No sessions yet")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Don’t show this again")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Use Manual Gateway")).toBe(true);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue