145 lines
5.1 KiB
Swift
145 lines
5.1 KiB
Swift
import SwiftUI
|
|
|
|
struct RootCanvas: View {
|
|
@EnvironmentObject private var appModel: NodeAppModel
|
|
@EnvironmentObject private var voiceWake: VoiceWakeManager
|
|
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
|
@State private var presentedSheet: PresentedSheet?
|
|
@State private var voiceWakeToastText: String?
|
|
@State private var toastDismissTask: Task<Void, Never>?
|
|
|
|
private enum PresentedSheet: Identifiable {
|
|
case settings
|
|
case chat
|
|
|
|
var id: Int {
|
|
switch self {
|
|
case .settings: 0
|
|
case .chat: 1
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topTrailing) {
|
|
ScreenTab()
|
|
|
|
VStack(spacing: 10) {
|
|
OverlayButton(systemImage: "text.bubble.fill") {
|
|
self.presentedSheet = .chat
|
|
}
|
|
.accessibilityLabel("Chat")
|
|
|
|
OverlayButton(systemImage: "gearshape.fill") {
|
|
self.presentedSheet = .settings
|
|
}
|
|
.accessibilityLabel("Settings")
|
|
}
|
|
.padding(.top, 10)
|
|
.padding(.trailing, 10)
|
|
}
|
|
.overlay(alignment: .topLeading) {
|
|
StatusPill(
|
|
bridge: self.bridgeStatus,
|
|
voiceWakeEnabled: self.voiceWakeEnabled,
|
|
onTap: { self.presentedSheet = .settings })
|
|
.padding(.leading, 10)
|
|
.safeAreaPadding(.top, 10)
|
|
}
|
|
.overlay(alignment: .topLeading) {
|
|
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
|
|
VoiceWakeToast(command: voiceWakeToastText)
|
|
.padding(.leading, 10)
|
|
.safeAreaPadding(.top, 58)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
}
|
|
}
|
|
.sheet(item: self.$presentedSheet) { sheet in
|
|
switch sheet {
|
|
case .settings:
|
|
SettingsTab()
|
|
case .chat:
|
|
ChatSheet(bridge: self.appModel.bridgeSession)
|
|
}
|
|
}
|
|
.preferredColorScheme(.dark)
|
|
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
|
|
guard let newValue else { return }
|
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
|
|
self.toastDismissTask?.cancel()
|
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
|
|
self.voiceWakeToastText = trimmed
|
|
}
|
|
|
|
self.toastDismissTask = Task {
|
|
try? await Task.sleep(nanoseconds: 2_300_000_000)
|
|
await MainActor.run {
|
|
withAnimation(.easeOut(duration: 0.25)) {
|
|
self.voiceWakeToastText = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onDisappear {
|
|
self.toastDismissTask?.cancel()
|
|
self.toastDismissTask = nil
|
|
}
|
|
}
|
|
|
|
private var bridgeStatus: StatusPill.BridgeState {
|
|
if self.appModel.bridgeServerName != nil { return .connected }
|
|
|
|
let text = self.appModel.bridgeStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if text.localizedCaseInsensitiveContains("connecting") ||
|
|
text.localizedCaseInsensitiveContains("reconnecting")
|
|
{
|
|
return .connecting
|
|
}
|
|
|
|
if text.localizedCaseInsensitiveContains("error") {
|
|
return .error
|
|
}
|
|
|
|
return .disconnected
|
|
}
|
|
}
|
|
|
|
private struct OverlayButton: View {
|
|
let systemImage: String
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: self.action) {
|
|
Image(systemName: self.systemImage)
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
.padding(10)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(.ultraThinMaterial)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
.white.opacity(0.18),
|
|
.white.opacity(0.04),
|
|
.clear,
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing))
|
|
.blendMode(.overlay)
|
|
}
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(.white.opacity(0.18), lineWidth: 0.5)
|
|
}
|
|
.shadow(color: .black.opacity(0.35), radius: 12, y: 6)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|