


















@@ -75,6 +75,363 @@ struct HomeToolbar: View {
7575}
7676}
777778+struct TalkToolbarTray: View {
79+var brighten: Bool
80+var tint: Color
81+var statusText: String
82+var agentName: String
83+var micLevel: Double
84+var isListening: Bool
85+var isSpeaking: Bool
86+var isUserSpeechDetected: Bool
87+var permissionState: TalkGatewayPermissionState
88+var onEnableTalk: () -> Void
89+var onStopTalk: () -> Void
90+91+@Environment(\.colorSchemeContrast) private var contrast
92+93+private var state: TalkToolbarTrayState {
94+TalkToolbarTrayState(
95+ statusText: self.statusText,
96+ isListening: self.isListening,
97+ isSpeaking: self.isSpeaking,
98+ isUserSpeechDetected: self.isUserSpeechDetected,
99+ permissionState: self.permissionState)
100+}
101+102+var body: some View {
103+HStack(spacing: 12) {
104+ZStack {
105+Circle()
106+.fill(self.tint.opacity(self.state.iconFillOpacity))
107+.frame(width: 36, height: 36)
108+Image(systemName: self.state.systemImage)
109+.font(.system(size: 15, weight: .semibold))
110+.foregroundStyle(self.state.iconColor(tint: self.tint))
111+}
112+113+VStack(alignment: .leading, spacing: 5) {
114+HStack(spacing: 8) {
115+Text(self.state.title)
116+.font(.subheadline.weight(.semibold))
117+.foregroundStyle(.primary)
118+.lineLimit(1)
119+120+if self.state.showsProgress {
121+ProgressView()
122+.controlSize(.mini)
123+}
124+}
125+126+HStack(spacing: 8) {
127+TalkWaveformView(
128+ mode: self.state.waveformMode(micLevel: self.micLevel),
129+ tint: self.state.waveformTint(tint: self.tint))
130+.frame(width: 84, height: 18)
131+.accessibilityHidden(true)
132+133+Text(self.subtitle)
134+.font(.caption.weight(.medium))
135+.foregroundStyle(.secondary)
136+.lineLimit(1)
137+}
138+}
139+140+Spacer(minLength: 0)
141+142+switch self.state.action {
143+case .enable:
144+Button(action: self.onEnableTalk) {
145+Label("Enable Talk", systemImage: "key.fill")
146+.labelStyle(.titleAndIcon)
147+}
148+.font(.caption.weight(.semibold))
149+.buttonStyle(.borderedProminent)
150+.controlSize(.small)
151+case .stop:
152+Button(action: self.onStopTalk) {
153+Image(systemName: "xmark")
154+.font(.system(size: 13, weight: .bold))
155+.frame(width: 28, height: 28)
156+}
157+.buttonStyle(.plain)
158+.background {
159+Circle()
160+.fill(Color.black.opacity(self.brighten ? 0.10 : 0.18))
161+.overlay {
162+Circle()
163+.strokeBorder(
164+.white.opacity(self.contrast == .increased ? 0.42 : 0.16),
165+ lineWidth: self.contrast == .increased ? 1.0 : 0.6)
166+}
167+}
168+.accessibilityLabel("Stop Talk")
169+case .none:
170+EmptyView()
171+}
172+}
173+.padding(.horizontal, 14)
174+.padding(.vertical, 12)
175+.frame(maxWidth: .infinity)
176+.background(.ultraThinMaterial)
177+.overlay(alignment: .top) {
178+Rectangle()
179+.fill(.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.18 : 0.12)))
180+.frame(height: self.contrast == .increased ? 1.0 : 0.6)
181+.allowsHitTesting(false)
182+}
183+.overlay(alignment: .bottom) {
184+LinearGradient(
185+ colors: [
186+self.tint.opacity(self.brighten ? 0.12 : 0.16),
187+.clear,
188+],
189+ startPoint: .leading,
190+ endPoint: .trailing)
191+.frame(height: 1)
192+.allowsHitTesting(false)
193+}
194+.accessibilityElement(children: .combine)
195+.accessibilityLabel("Talk Mode")
196+.accessibilityValue("\(self.state.title), \(self.subtitle)")
197+}
198+199+private var subtitle: String {
200+let trimmedAgent = self.agentName.trimmingCharacters(in: .whitespacesAndNewlines)
201+if self.state.prefersPermissionCopy {
202+return "Gateway approval needed"
203+}
204+if !trimmedAgent.isEmpty {
205+return trimmedAgent
206+}
207+return "OpenClaw"
208+}
209+}
210+211+private enum TalkToolbarTrayAction {
212+case none
213+case enable
214+case stop
215+}
216+217+private enum TalkWaveformMode: Equatable {
218+case level(Double)
219+case inputSpeech
220+case speaking
221+case indeterminate
222+case still
223+}
224+225+private struct TalkToolbarTrayState: Equatable {
226+let statusText: String
227+let isListening: Bool
228+let isSpeaking: Bool
229+let isUserSpeechDetected: Bool
230+let permissionState: TalkGatewayPermissionState
231+232+private var normalizedStatus: String {
233+self.statusText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
234+}
235+236+var title: String {
237+switch self.permissionState {
238+case .missingScope, .requestFailed:
239+return "Gateway permission required"
240+case .requestingUpgrade:
241+return "Requesting approval"
242+case .upgradeRequested:
243+return "Approval requested"
244+default:
245+break
246+}
247+248+if self.isSpeaking { return "Speaking" }
249+if self.isListening { return "Listening" }
250+if self.normalizedStatus.contains("connecting") { return "Connecting" }
251+if self.normalizedStatus.contains("thinking") { return "Asking OpenClaw" }
252+if self.normalizedStatus == "ready" { return "Ready to talk" }
253+if self.normalizedStatus.isEmpty || self.normalizedStatus == "off" { return "Talk" }
254+return self.statusText
255+}
256+257+var systemImage: String {
258+switch self.permissionState {
259+case .missingScope, .requestFailed:
260+return "key.fill"
261+case .requestingUpgrade:
262+return "paperplane.fill"
263+case .upgradeRequested:
264+return "hourglass"
265+default:
266+break
267+}
268+269+if self.isSpeaking { return "speaker.wave.2.fill" }
270+if self.isListening { return "mic.fill" }
271+if self.normalizedStatus.contains("thinking") { return "sparkles" }
272+if self.normalizedStatus.contains("connecting") { return "dot.radiowaves.left.and.right" }
273+return "waveform"
274+}
275+276+var action: TalkToolbarTrayAction {
277+switch self.permissionState {
278+case .missingScope, .requestFailed:
279+.enable
280+case .requestingUpgrade, .upgradeRequested:
281+.none
282+default:
283+.stop
284+}
285+}
286+287+var showsProgress: Bool {
288+switch self.permissionState {
289+case .requestingUpgrade, .upgradeRequested:
290+true
291+default:
292+self.normalizedStatus.contains("connecting") || self.normalizedStatus.contains("thinking")
293+}
294+}
295+296+var prefersPermissionCopy: Bool {
297+switch self.permissionState {
298+case .missingScope, .requestingUpgrade, .upgradeRequested, .requestFailed:
299+true
300+default:
301+false
302+}
303+}
304+305+var iconFillOpacity: Double {
306+self.prefersPermissionCopy ? 0.18 : 0.24
307+}
308+309+func iconColor(tint: Color) -> Color {
310+switch self.permissionState {
311+case .requestFailed:
312+.red
313+case .missingScope, .requestingUpgrade, .upgradeRequested:
314+.orange
315+default:
316+ tint
317+}
318+}
319+320+func waveformTint(tint: Color) -> Color {
321+switch self.permissionState {
322+case .requestFailed:
323+.red
324+case .missingScope, .requestingUpgrade, .upgradeRequested:
325+.orange
326+default:
327+ tint
328+}
329+}
330+331+func waveformMode(micLevel: Double) -> TalkWaveformMode {
332+switch self.permissionState {
333+case .requestingUpgrade, .upgradeRequested:
334+return .indeterminate
335+case .missingScope, .requestFailed:
336+return .still
337+default:
338+break
339+}
340+341+if self.isSpeaking {
342+return .speaking
343+}
344+if self.isListening, self.isUserSpeechDetected {
345+return .inputSpeech
346+}
347+if self.isListening {
348+return .level(micLevel)
349+}
350+if self.normalizedStatus.contains("connecting") || self.normalizedStatus.contains("thinking") {
351+return .indeterminate
352+}
353+return .still
354+}
355+}
356+357+private struct TalkWaveformView: View {
358+var mode: TalkWaveformMode
359+var tint: Color
360+361+@Environment(\.accessibilityReduceMotion) private var reduceMotion
362+363+private let barCount = 14
364+365+var body: some View {
366+TimelineView(.periodic(from: .now, by: 1.0 / 24.0)) { timeline in
367+HStack(alignment: .center, spacing: 3) {
368+ForEach(0..<self.barCount, id: \.self) { index in
369+Capsule(style: .continuous)
370+.fill(self.tint.opacity(self.opacity(for: index)))
371+.frame(width: 3, height: self.height(for: index, date: timeline.date))
372+}
373+}
374+.frame(maxHeight: .infinity)
375+}
376+}
377+378+private func height(for index: Int, date: Date) -> CGFloat {
379+let minimum: Double = 4
380+let maximum: Double = 18
381+let amplitude = self.amplitude(for: index, date: date)
382+return CGFloat(minimum + ((maximum - minimum) * amplitude))
383+}
384+385+private func opacity(for index: Int) -> Double {
386+switch self.mode {
387+case .still:
388+ index == self.barCount / 2 ? 0.64 : 0.32
389+default:
390+0.78
391+}
392+}
393+394+private func amplitude(for index: Int, date: Date) -> Double {
395+if self.reduceMotion {
396+switch self.mode {
397+case let .level(level):
398+return min(max(level, 0.10), 1.0)
399+case .inputSpeech:
400+return 0.72
401+case .speaking:
402+return 0.62
403+case .indeterminate:
404+return 0.34
405+case .still:
406+return 0.18
407+}
408+}
409+410+let t = date.timeIntervalSinceReferenceDate
411+let phase = Double(index) * 0.52
412+switch self.mode {
413+case let .level(level):
414+let clamped = min(max(level, 0), 1)
415+let shaped = 0.12 + (0.88 * clamped)
416+let variation = 0.72 + (0.28 * sin((t * 12.0) + phase))
417+return min(max(shaped * variation, 0.10), 1.0)
418+case .inputSpeech:
419+let primary = 0.5 + (0.5 * sin((t * 14.0) + phase))
420+let secondary = 0.5 + (0.5 * sin((t * 5.0) + (phase * 1.35)))
421+return min(max(0.16 + (0.60 * primary) + (0.24 * secondary), 0.14), 1.0)
422+case .speaking:
423+let wave = 0.5 + (0.5 * sin((t * 7.5) + phase))
424+let secondary = 0.5 + (0.5 * sin((t * 3.0) + (phase * 0.7)))
425+return min(max(0.18 + (0.58 * wave) + (0.24 * secondary), 0.12), 1.0)
426+case .indeterminate:
427+let center = (sin((t * 3.2) + phase) + 1) / 2
428+return 0.16 + (0.42 * center)
429+case .still:
430+return index == self.barCount / 2 ? 0.32 : 0.16
431+}
432+}
433+}
434+78435private struct HomeToolbarStatusButton: View {
79436@Environment(\.scenePhase) private var scenePhase
80437@Environment(\.accessibilityReduceMotion) private var reduceMotion
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。