625 lines
20 KiB
Swift
625 lines
20 KiB
Swift
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<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
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|