import Observation import SwiftUI enum PhoneRoute: Hashable { case chat(String) case search(String) case draftChat case draftSearch case settings static func from(selection: SidebarSelection) -> PhoneRoute { switch selection { case let .chat(chatID): return .chat(chatID) case let .search(searchID): return .search(searchID) case .settings: return .settings } } } struct SybilPhoneShellView: View { @Bindable var viewModel: SybilViewModel @State private var route: PhoneRoute = .draftChat @Environment(\.scenePhase) private var scenePhase @State private var shouldRefreshOnForeground = false @State private var composerFocusRequest = 0 @State private var phoneStackWidth: CGFloat = BackSwipeMetrics.referenceWidth @State private var isSidebarOverlayPresented = false @State private var sidebarSwipeOffset: CGFloat = 0 @State private var sidebarSwipeIsActive = false @State private var sidebarSwipeIsCompleting = false @State private var sidebarSwipeHasLatched = false @State private var sidebarHighlightSelection: SidebarSelection? @State private var sidebarHighlightClearTask: Task? @State private var openingSelectionRequestID: UUID? private var canRecognizeSidebarSwipe: Bool { !isSidebarOverlayPresented && !sidebarSwipeIsCompleting } private var sidebarOverlayProgress: CGFloat { if isSidebarOverlayPresented { return 1 } return SidebarOverlaySwipeMetrics.progress( for: sidebarSwipeOffset, width: phoneStackWidth ) } private var shouldRenderSidebarOverlay: Bool { isSidebarOverlayPresented || sidebarSwipeIsActive || sidebarSwipeIsCompleting || sidebarOverlayProgress > 0.001 } private var currentRouteSelection: SidebarSelection? { switch route { case let .chat(chatID): return .chat(chatID) case let .search(searchID): return .search(searchID) case .draftChat, .draftSearch, .settings: return nil } } private var highlightedSidebarSelection: SidebarSelection? { sidebarHighlightSelection ?? currentRouteSelection } var body: some View { GeometryReader { proxy in phoneStack(width: proxy.size.width) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .onAppear { updatePhoneStackWidth(proxy.size.width) } .onChange(of: proxy.size.width) { _, width in updatePhoneStackWidth(width) } } .tint(SybilTheme.primary) .animation(.easeOut(duration: 0.22), value: route) .animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented) .onChange(of: scenePhase) { _, nextPhase in switch nextPhase { case .background: shouldRefreshOnForeground = true viewModel.markAppInactiveForNetwork() case .active: viewModel.markAppActiveForNetwork() guard shouldRefreshOnForeground else { return } shouldRefreshOnForeground = false Task { await viewModel.refreshAfterAppBecameActive( refreshCollections: isSidebarOverlayPresented, refreshSelection: !isSidebarOverlayPresented && viewModel.hasRefreshableSelection ) } case .inactive: shouldRefreshOnForeground = true viewModel.markAppInactiveForNetwork() @unknown default: break } } } private func phoneStack(width: CGFloat) -> some View { ZStack(alignment: .topLeading) { phoneWorkspaceLayer .zIndex(0) phoneSidebarOverlayLayer(width: width) .zIndex(1) } } private var phoneWorkspaceLayer: some View { SybilPhoneDestinationView( viewModel: viewModel, composerFocusRequest: $composerFocusRequest, route: route, onRequestBack: { _ in showSidebarOverlay() }, onRequestNewChat: sidebarWorkspaceNewChatAction, onShowSidebar: showSidebarOverlay ) .background(SybilTheme.background) .blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress)) .opacity(SidebarOverlaySwipeMetrics.workspaceOpacity(for: sidebarOverlayProgress)) .allowsHitTesting(!shouldRenderSidebarOverlay) .background { sidebarSwipeInstaller } } private func phoneSidebarOverlayLayer(width: CGFloat) -> some View { VStack(spacing: 0) { phoneOverlayTopBar SybilPhoneSidebarRoot( viewModel: viewModel, highlightedSelection: highlightedSidebarSelection, onSelect: openSidebarSelection, onRoute: showRouteAndClearSidebarHighlight ) } .opacity(sidebarOverlayProgress) .blur(radius: SidebarOverlaySwipeMetrics.overlayBlurRadius(for: sidebarOverlayProgress)) .offset(x: SidebarOverlaySwipeMetrics.overlayOffset(for: sidebarOverlayProgress, width: width)) .allowsHitTesting(isSidebarOverlayPresented) .accessibilityHidden(!isSidebarOverlayPresented) } private var sidebarSwipeInstaller: some View { WorkspaceSwipePanInstaller( direction: .right, isEnabled: canRecognizeSidebarSwipe, onBegan: { width in beginSidebarSwipe(containerWidth: width) }, onChanged: { translationX, width in updateSidebarSwipe(with: translationX, containerWidth: width) }, onEnded: { translationX, width, velocityX, didFinish in finishSidebarSwipe( translationX: translationX, containerWidth: width, velocityX: velocityX, didFinish: didFinish ) } ) .frame(maxWidth: .infinity, maxHeight: .infinity) } private var sidebarWorkspaceNewChatAction: (() -> Void)? { guard !isSidebarOverlayPresented else { return nil } return { startNewChatFromDestination() } } private var phoneOverlayTopBar: some View { HStack(spacing: 12) { SybilWordmark(size: 21) Spacer() Button { hideSidebarOverlay() } label: { Image(systemName: "chevron.right.2") .font(.system(size: 21, weight: .bold)) .foregroundStyle(SybilTheme.text) .frame(width: 54, height: 54) .background( Circle() .fill(.ultraThinMaterial) .overlay( Circle() .fill(SybilTheme.surface.opacity(0.76)) ) ) .overlay( Circle() .stroke(SybilTheme.border.opacity(0.64), lineWidth: 1) ) } .buttonStyle(.plain) .accessibilityLabel("Hide conversations") } .padding(.horizontal, 16) .padding(.top, 10) .padding(.bottom, 12) .background { SybilPhoneOverlayBlurBand(edge: .top) .ignoresSafeArea(edges: .top) } } private func updatePhoneStackWidth(_ width: CGFloat) { phoneStackWidth = max(width, 1) } private func startNewChatFromDestination() { viewModel.startNewChat() composerFocusRequest += 1 showRoute(.draftChat) } private func showRoute(_ nextRoute: PhoneRoute) { let update = { route = nextRoute } if isSidebarOverlayPresented { withAnimation(.easeOut(duration: 0.22)) { update() isSidebarOverlayPresented = false } } else { update() } resetSidebarSwipe(animated: false) } private func showRouteAndClearSidebarHighlight(_ nextRoute: PhoneRoute) { showRoute(nextRoute) clearSidebarHighlight() } private func showSidebarOverlay() { withAnimation(.easeOut(duration: 0.18)) { isSidebarOverlayPresented = true } resetSidebarSwipe(animated: false) } private func hideSidebarOverlay() { withAnimation(.easeOut(duration: 0.18)) { isSidebarOverlayPresented = false } resetSidebarSwipe(animated: false) } private func openSidebarSelection(_ selection: SidebarSelection) { if openingSelectionRequestID != nil, sidebarHighlightSelection == selection { return } let requestID = UUID() openingSelectionRequestID = requestID setSidebarHighlight(selection) Task { await viewModel.selectForNavigation(selection) guard openingSelectionRequestID == requestID else { return } showRoute(PhoneRoute.from(selection: selection)) openingSelectionRequestID = nil clearSidebarHighlight(selection, after: .milliseconds(260)) } } private func setSidebarHighlight(_ selection: SidebarSelection) { sidebarHighlightClearTask?.cancel() sidebarHighlightSelection = selection } private func clearSidebarHighlight(_ selection: SidebarSelection, after delay: Duration) { sidebarHighlightClearTask?.cancel() sidebarHighlightClearTask = Task { @MainActor in try? await Task.sleep(for: delay) guard !Task.isCancelled, sidebarHighlightSelection == selection, openingSelectionRequestID == nil else { return } sidebarHighlightSelection = nil } } private func clearSidebarHighlight() { sidebarHighlightClearTask?.cancel() openingSelectionRequestID = nil sidebarHighlightSelection = nil } private func beginSidebarSwipe(containerWidth: CGFloat) { let update = { phoneStackWidth = max(containerWidth, 1) sidebarSwipeIsActive = true sidebarSwipeHasLatched = false } var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction, update) } private func updateSidebarSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) { let nextOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth) let nextLatched = SidebarOverlaySwipeMetrics.isLatched( offset: nextOffset, width: containerWidth, isCurrentlyLatched: sidebarSwipeHasLatched ) var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { phoneStackWidth = max(containerWidth, 1) sidebarSwipeOffset = nextOffset sidebarSwipeHasLatched = nextLatched } } private func finishSidebarSwipe( translationX: CGFloat, containerWidth: CGFloat, velocityX: CGFloat, didFinish: Bool ) { guard sidebarSwipeIsActive else { resetSidebarSwipe(animated: false) return } let finalOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: translationX, width: containerWidth) let finalLatched = SidebarOverlaySwipeMetrics.isLatched( offset: finalOffset, width: containerWidth, isCurrentlyLatched: sidebarSwipeHasLatched ) updateSidebarSwipe(with: translationX, containerWidth: containerWidth) if didFinish && SidebarOverlaySwipeMetrics.shouldComplete( offset: finalOffset, velocityX: velocityX, width: containerWidth, isLatched: finalLatched ) { completeSidebarSwipe() return } resetSidebarSwipe(animated: true, velocityX: velocityX) } private func completeSidebarSwipe() { guard !sidebarSwipeIsCompleting else { return } sidebarSwipeIsCompleting = true withAnimation(.easeOut(duration: 0.18)) { isSidebarOverlayPresented = true } resetSidebarSwipe(animated: false) } private func resetSidebarSwipe(animated: Bool, velocityX: CGFloat = 0) { let currentOffset = sidebarSwipeOffset let reset = { sidebarSwipeOffset = 0 sidebarSwipeIsActive = false sidebarSwipeIsCompleting = false sidebarSwipeHasLatched = false } if animated { withAnimation( SidebarOverlaySwipeMetrics.springAnimation( currentOffset: currentOffset, targetOffset: 0, velocityX: velocityX ) ) { reset() } } else { reset() } } } private enum SidebarOverlaySwipeMetrics { static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat { BackSwipeMetrics.clampedOffset(for: rawTranslation, width: width) } static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat { BackSwipeMetrics.progress(for: offset, width: width) } static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool { BackSwipeMetrics.isLatched(offset: offset, width: width, isCurrentlyLatched: isCurrentlyLatched) } static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool { BackSwipeMetrics.shouldComplete(offset: offset, velocityX: velocityX, width: width, isLatched: isLatched) } static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation { BackSwipeMetrics.springAnimation(currentOffset: currentOffset, targetOffset: targetOffset, velocityX: velocityX) } static func overlayOffset(for progress: CGFloat, width: CGFloat) -> CGFloat { -(1 - min(max(progress, 0), 1)) * min(max(width * 0.18, 44), 76) } static func overlayBlurRadius(for progress: CGFloat) -> CGFloat { (1 - min(max(progress, 0), 1)) * 18 } static func workspaceBlurRadius(for progress: CGFloat) -> CGFloat { min(max(progress, 0), 1) * 14 } static func workspaceOpacity(for progress: CGFloat) -> CGFloat { 1 - (min(max(progress, 0), 1) * 0.22) } } private struct SybilPhoneOverlayBlurBand: View { var edge: VerticalEdge var body: some View { ZStack { Rectangle() .fill(.ultraThinMaterial) .opacity(0.34) Rectangle() .fill( LinearGradient( colors: gradientColors, startPoint: edge == .top ? .top : .bottom, endPoint: edge == .top ? .bottom : .top ) ) } } private var gradientColors: [Color] { [ Color.black.opacity(0.94), SybilTheme.background.opacity(0.78), Color.black.opacity(0) ] } } private struct SybilPhoneSidebarRoot: View { @Bindable var viewModel: SybilViewModel var highlightedSelection: SidebarSelection? var onSelect: (SidebarSelection) -> Void var onRoute: (PhoneRoute) -> Void var body: some View { VStack(spacing: 0) { if let errorMessage = viewModel.errorMessage { Text(errorMessage) .font(.sybil(.footnote)) .foregroundStyle(SybilTheme.danger) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 12) .padding(.vertical, 10) Divider() .overlay(SybilTheme.border) } SybilSidebarItemList( viewModel: viewModel, isSelected: { item in highlightedSelection == item.selection }, onSelect: { item in onSelect(item.selection) } ) } .background(SybilTheme.panelGradient) .safeAreaInset(edge: .bottom, spacing: 0) { bottomToolbar } } private var bottomToolbar: some View { VStack(spacing: 0) { Divider() .overlay(SybilTheme.border) HStack(spacing: 12) { toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") { viewModel.openSettings() onRoute(.settings) } Spacer() toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") { viewModel.startNewSearch() onRoute(.draftSearch) } toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) { viewModel.startNewChat() onRoute(.draftChat) } } .padding(.horizontal, 18) .padding(.vertical, 10) .background(SybilTheme.panelGradient) } } private func toolbarIconButton( systemImage: String, accessibilityLabel: String, isPrimary: Bool = false, action: @escaping () -> Void ) -> some View { Button(action: action) { Image(systemName: systemImage) .font(.system(size: 18, weight: .semibold)) .foregroundStyle(isPrimary ? SybilTheme.text : SybilTheme.textMuted) .frame(width: 42, height: 42) .background( Circle() .fill( isPrimary ? AnyShapeStyle(SybilTheme.primaryGradient) : AnyShapeStyle(SybilTheme.surface.opacity(0.78)) ) ) .overlay( Circle() .stroke(isPrimary ? SybilTheme.primary.opacity(0.42) : SybilTheme.border.opacity(0.76), lineWidth: 1) ) } .buttonStyle(.plain) .accessibilityLabel(accessibilityLabel) } } private struct SybilPhoneDestinationView: View { @Bindable var viewModel: SybilViewModel @Binding var composerFocusRequest: Int let route: PhoneRoute let onRequestBack: (_ animateNavigation: Bool) -> Void let onRequestNewChat: (() -> Void)? let onShowSidebar: () -> Void var body: some View { SybilWorkspaceView( viewModel: viewModel, composerFocusRequest: composerFocusRequest, navigationLeadingControl: .showSidebar, onShowSidebar: onShowSidebar, onRequestBack: onRequestBack, onRequestNewChat: onRequestNewChat ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .task(id: route) { applyRoute() } } private func applyRoute() { switch route { case let .chat(chatID): guard viewModel.draftKind != nil || viewModel.selectedItem != .chat(chatID) else { return } viewModel.select(.chat(chatID)) case let .search(searchID): guard viewModel.draftKind != nil || viewModel.selectedItem != .search(searchID) else { return } viewModel.select(.search(searchID)) case .draftChat: viewModel.startNewChat() case .draftSearch: viewModel.startNewSearch() case .settings: viewModel.openSettings() } } }