Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilPhoneShellView.swift

625 lines
20 KiB
Swift
Raw Normal View History

2026-02-20 00:09:02 -08:00
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
2026-05-04 21:07:55 -07:00
@State private var route: PhoneRoute = .draftChat
2026-05-02 22:18:33 -07:00
@Environment(\.scenePhase) private var scenePhase
@State private var shouldRefreshOnForeground = false
2026-05-02 22:46:25 -07:00
@State private var composerFocusRequest = 0
@State private var phoneStackWidth: CGFloat = BackSwipeMetrics.referenceWidth
2026-05-04 21:07:55 -07:00
@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<Void, Never>?
@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
}
2026-05-04 21:07:55 -07:00
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
}
}
2026-05-04 21:07:55 -07:00
private var highlightedSidebarSelection: SidebarSelection? {
sidebarHighlightSelection ?? currentRouteSelection
}
2026-02-20 00:09:02 -08:00
var body: some View {
GeometryReader { proxy in
2026-05-04 21:07:55 -07:00
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)
}
2026-02-20 00:09:02 -08:00
}
.tint(SybilTheme.primary)
2026-05-04 21:07:55 -07:00
.animation(.easeOut(duration: 0.22), value: route)
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
2026-05-02 22:18:33 -07:00
.onChange(of: scenePhase) { _, nextPhase in
switch nextPhase {
case .background:
shouldRefreshOnForeground = true
2026-05-03 16:42:49 -07:00
viewModel.markAppInactiveForNetwork()
2026-05-02 22:18:33 -07:00
case .active:
2026-05-03 16:42:49 -07:00
viewModel.markAppActiveForNetwork()
2026-05-02 22:18:33 -07:00
guard shouldRefreshOnForeground else {
return
}
shouldRefreshOnForeground = false
Task {
2026-05-03 16:42:49 -07:00
await viewModel.refreshAfterAppBecameActive(
2026-05-04 21:07:55 -07:00
refreshCollections: isSidebarOverlayPresented,
refreshSelection: !isSidebarOverlayPresented && viewModel.hasRefreshableSelection
2026-05-02 22:18:33 -07:00
)
}
case .inactive:
2026-05-03 16:42:49 -07:00
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
2026-05-02 22:18:33 -07:00
@unknown default:
break
}
}
2026-02-20 00:09:02 -08:00
}
2026-05-04 21:07:55 -07:00
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()
2026-05-04 21:07:55 -07:00
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 {
2026-05-04 21:07:55 -07:00
SybilPhoneOverlayBlurBand(edge: .top)
.ignoresSafeArea(edges: .top)
}
}
private func updatePhoneStackWidth(_ width: CGFloat) {
phoneStackWidth = max(width, 1)
}
2026-05-04 21:07:55 -07:00
private func startNewChatFromDestination() {
viewModel.startNewChat()
composerFocusRequest += 1
showRoute(.draftChat)
}
private func showRoute(_ nextRoute: PhoneRoute) {
let update = {
route = nextRoute
}
2026-05-04 21:07:55 -07:00
if isSidebarOverlayPresented {
withAnimation(.easeOut(duration: 0.22)) {
update()
isSidebarOverlayPresented = false
}
} else {
2026-05-04 21:07:55 -07:00
update()
}
2026-05-04 21:07:55 -07:00
resetSidebarSwipe(animated: false)
}
2026-05-04 21:07:55 -07:00
private func showRouteAndClearSidebarHighlight(_ nextRoute: PhoneRoute) {
showRoute(nextRoute)
clearSidebarHighlight()
}
2026-05-04 21:07:55 -07:00
private func showSidebarOverlay() {
withAnimation(.easeOut(duration: 0.18)) {
isSidebarOverlayPresented = true
}
2026-05-04 21:07:55 -07:00
resetSidebarSwipe(animated: false)
}
2026-05-04 21:07:55 -07:00
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
}
2026-05-04 21:07:55 -07:00
showRoute(PhoneRoute.from(selection: selection))
openingSelectionRequestID = nil
clearSidebarHighlight(selection, after: .milliseconds(260))
}
2026-05-04 21:07:55 -07:00
}
2026-05-04 21:07:55 -07:00
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
}
2026-05-04 21:07:55 -07:00
sidebarHighlightSelection = nil
}
}
2026-05-04 21:07:55 -07:00
private func clearSidebarHighlight() {
sidebarHighlightClearTask?.cancel()
openingSelectionRequestID = nil
sidebarHighlightSelection = nil
}
private func beginSidebarSwipe(containerWidth: CGFloat) {
let update = {
2026-05-04 21:07:55 -07:00
phoneStackWidth = max(containerWidth, 1)
sidebarSwipeIsActive = true
sidebarSwipeHasLatched = false
}
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction, update)
}
2026-05-04 21:07:55 -07:00
private func updateSidebarSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
let nextOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
let nextLatched = SidebarOverlaySwipeMetrics.isLatched(
offset: nextOffset,
width: containerWidth,
2026-05-04 21:07:55 -07:00
isCurrentlyLatched: sidebarSwipeHasLatched
)
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
2026-05-04 21:07:55 -07:00
phoneStackWidth = max(containerWidth, 1)
sidebarSwipeOffset = nextOffset
sidebarSwipeHasLatched = nextLatched
}
}
2026-05-04 21:07:55 -07:00
private func finishSidebarSwipe(
translationX: CGFloat,
containerWidth: CGFloat,
velocityX: CGFloat,
didFinish: Bool
) {
2026-05-04 21:07:55 -07:00
guard sidebarSwipeIsActive else {
resetSidebarSwipe(animated: false)
return
}
2026-05-04 21:07:55 -07:00
let finalOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
let finalLatched = SidebarOverlaySwipeMetrics.isLatched(
offset: finalOffset,
width: containerWidth,
2026-05-04 21:07:55 -07:00
isCurrentlyLatched: sidebarSwipeHasLatched
)
2026-05-04 21:07:55 -07:00
updateSidebarSwipe(with: translationX, containerWidth: containerWidth)
2026-05-04 21:07:55 -07:00
if didFinish && SidebarOverlaySwipeMetrics.shouldComplete(
offset: finalOffset,
velocityX: velocityX,
width: containerWidth,
isLatched: finalLatched
) {
2026-05-04 21:07:55 -07:00
completeSidebarSwipe()
return
}
2026-05-04 21:07:55 -07:00
resetSidebarSwipe(animated: true, velocityX: velocityX)
}
2026-05-04 21:07:55 -07:00
private func completeSidebarSwipe() {
guard !sidebarSwipeIsCompleting else {
return
}
2026-05-04 21:07:55 -07:00
sidebarSwipeIsCompleting = true
withAnimation(.easeOut(duration: 0.18)) {
isSidebarOverlayPresented = true
}
2026-05-04 21:07:55 -07:00
resetSidebarSwipe(animated: false)
}
2026-05-04 21:07:55 -07:00
private func resetSidebarSwipe(animated: Bool, velocityX: CGFloat = 0) {
let currentOffset = sidebarSwipeOffset
let reset = {
2026-05-04 21:07:55 -07:00
sidebarSwipeOffset = 0
sidebarSwipeIsActive = false
sidebarSwipeIsCompleting = false
sidebarSwipeHasLatched = false
}
if animated {
withAnimation(
2026-05-04 21:07:55 -07:00
SidebarOverlaySwipeMetrics.springAnimation(
currentOffset: currentOffset,
targetOffset: 0,
velocityX: velocityX
)
) {
reset()
}
} else {
reset()
}
}
2026-02-20 00:09:02 -08:00
}
2026-05-04 21:07:55 -07:00
private enum SidebarOverlaySwipeMetrics {
static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
BackSwipeMetrics.clampedOffset(for: rawTranslation, width: width)
}
2026-05-04 21:07:55 -07:00
static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
BackSwipeMetrics.progress(for: offset, width: width)
}
2026-05-04 21:07:55 -07:00
static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
BackSwipeMetrics.isLatched(offset: offset, width: width, isCurrentlyLatched: isCurrentlyLatched)
}
2026-05-04 21:07:55 -07:00
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
)
)
}
}
2026-02-20 00:09:02 -08:00
2026-05-04 21:07:55 -07:00
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
2026-02-20 00:09:02 -08:00
var body: some View {
VStack(spacing: 0) {
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
2026-05-02 16:23:00 -07:00
.font(.sybil(.footnote))
2026-02-20 00:09:02 -08:00
.foregroundStyle(SybilTheme.danger)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 10)
Divider()
.overlay(SybilTheme.border)
}
2026-05-04 20:19:58 -07:00
SybilSidebarItemList(
viewModel: viewModel,
isSelected: { item in
highlightedSelection == item.selection
},
onSelect: { item in
2026-05-04 21:07:55 -07:00
onSelect(item.selection)
2026-02-20 00:09:02 -08:00
}
2026-05-04 20:19:58 -07:00
)
2026-05-02 16:23:00 -07:00
}
.background(SybilTheme.panelGradient)
.safeAreaInset(edge: .bottom, spacing: 0) {
bottomToolbar
}
}
2026-02-20 00:09:02 -08:00
2026-05-02 16:23:00 -07:00
private var bottomToolbar: some View {
VStack(spacing: 0) {
2026-02-20 00:09:02 -08:00
Divider()
.overlay(SybilTheme.border)
2026-05-02 16:23:00 -07:00
HStack(spacing: 12) {
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
2026-05-04 21:07:55 -07:00
viewModel.openSettings()
onRoute(.settings)
2026-05-02 16:23:00 -07:00
}
Spacer()
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
viewModel.startNewSearch()
2026-05-04 21:07:55 -07:00
onRoute(.draftSearch)
2026-05-02 16:23:00 -07:00
}
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
viewModel.startNewChat()
2026-05-04 21:07:55 -07:00
onRoute(.draftChat)
2026-05-02 16:23:00 -07:00
}
2026-02-20 00:09:02 -08:00
}
2026-05-02 16:23:00 -07:00
.padding(.horizontal, 18)
.padding(.vertical, 10)
.background(SybilTheme.panelGradient)
2026-02-20 00:09:02 -08:00
}
}
2026-05-02 16:23:00 -07:00
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)
)
2026-02-20 00:09:02 -08:00
}
2026-05-02 16:23:00 -07:00
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
2026-02-20 00:09:02 -08:00
}
}
2026-02-20 00:09:02 -08:00
private struct SybilPhoneDestinationView: View {
@Bindable var viewModel: SybilViewModel
2026-05-02 22:46:25 -07:00
@Binding var composerFocusRequest: Int
2026-02-20 00:09:02 -08:00
let route: PhoneRoute
let onRequestBack: (_ animateNavigation: Bool) -> Void
2026-05-04 21:07:55 -07:00
let onRequestNewChat: (() -> Void)?
let onShowSidebar: () -> Void
2026-02-20 00:09:02 -08:00
var body: some View {
2026-05-03 17:52:57 -07:00
SybilWorkspaceView(
viewModel: viewModel,
2026-05-03 21:14:10 -07:00
composerFocusRequest: composerFocusRequest,
2026-05-04 21:07:55 -07:00
navigationLeadingControl: .showSidebar,
onShowSidebar: onShowSidebar,
onRequestBack: onRequestBack,
onRequestNewChat: onRequestNewChat
2026-05-03 21:14:10 -07:00
)
2026-05-02 22:46:25 -07:00
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task(id: route) {
applyRoute()
}
2026-02-20 00:09:02 -08:00
}
private func applyRoute() {
switch route {
case let .chat(chatID):
guard viewModel.draftKind != nil || viewModel.selectedItem != .chat(chatID) else {
return
}
2026-02-20 00:09:02 -08:00
viewModel.select(.chat(chatID))
case let .search(searchID):
guard viewModel.draftKind != nil || viewModel.selectedItem != .search(searchID) else {
return
}
2026-02-20 00:09:02 -08:00
viewModel.select(.search(searchID))
case .draftChat:
viewModel.startNewChat()
case .draftSearch:
viewModel.startNewSearch()
case .settings:
viewModel.openSettings()
}
}
}