Compare commits
2 Commits
be072fd46d
...
195e157e1a
| Author | SHA1 | Date | |
|---|---|---|---|
| 195e157e1a | |||
| c5dbd12587 |
@@ -22,75 +22,60 @@ enum PhoneRoute: Hashable {
|
|||||||
|
|
||||||
struct SybilPhoneShellView: View {
|
struct SybilPhoneShellView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
@State private var path: [PhoneRoute] = []
|
@State private var route: PhoneRoute = .draftChat
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@State private var shouldRefreshOnForeground = false
|
@State private var shouldRefreshOnForeground = false
|
||||||
@State private var composerFocusRequest = 0
|
@State private var composerFocusRequest = 0
|
||||||
@State private var phoneStackWidth: CGFloat = BackSwipeMetrics.referenceWidth
|
@State private var phoneStackWidth: CGFloat = BackSwipeMetrics.referenceWidth
|
||||||
@State private var backSwipeOffset: CGFloat = 0
|
@State private var isSidebarOverlayPresented = false
|
||||||
@State private var backSwipeCompletionOffset: CGFloat = 0
|
@State private var sidebarSwipeOffset: CGFloat = 0
|
||||||
@State private var backSwipeIsActive = false
|
@State private var sidebarSwipeIsActive = false
|
||||||
@State private var backSwipeIsCompleting = false
|
@State private var sidebarSwipeIsCompleting = false
|
||||||
@State private var backSwipeHasLatched = 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 canRecognizeBackSwipe: Bool {
|
private var canRecognizeSidebarSwipe: Bool {
|
||||||
!path.isEmpty && !backSwipeIsCompleting
|
!isSidebarOverlayPresented && !sidebarSwipeIsCompleting
|
||||||
}
|
}
|
||||||
|
|
||||||
private var backSwipeVisualOffset: CGFloat {
|
private var sidebarOverlayProgress: CGFloat {
|
||||||
backSwipeOffset + backSwipeCompletionOffset
|
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 {
|
var body: some View {
|
||||||
GeometryReader { proxy in
|
GeometryReader { proxy in
|
||||||
ZStack(alignment: .topLeading) {
|
phoneStack(width: proxy.size.width)
|
||||||
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
|
|
||||||
.safeAreaInset(edge: .top, spacing: 0) {
|
|
||||||
phoneRootTopBar
|
|
||||||
}
|
|
||||||
.zIndex(0)
|
|
||||||
|
|
||||||
if let route = path.last {
|
|
||||||
SybilPhoneDestinationView(
|
|
||||||
viewModel: viewModel,
|
|
||||||
composerFocusRequest: $composerFocusRequest,
|
|
||||||
route: route,
|
|
||||||
onRequestBack: requestBack,
|
|
||||||
onRequestNewChat: startNewChatFromDestination
|
|
||||||
)
|
|
||||||
.background(SybilTheme.background)
|
|
||||||
.offset(x: backSwipeVisualOffset)
|
|
||||||
.shadow(
|
|
||||||
color: backSwipeVisualOffset > 0 ? Color.black.opacity(0.34) : Color.clear,
|
|
||||||
radius: backSwipeVisualOffset > 0 ? 18 : 0,
|
|
||||||
x: -8,
|
|
||||||
y: 0
|
|
||||||
)
|
|
||||||
.transition(.move(edge: .trailing))
|
|
||||||
.zIndex(1)
|
|
||||||
.background {
|
|
||||||
WorkspaceSwipePanInstaller(
|
|
||||||
direction: .right,
|
|
||||||
isEnabled: canRecognizeBackSwipe,
|
|
||||||
onBegan: { width in
|
|
||||||
beginBackSwipe(containerWidth: width)
|
|
||||||
},
|
|
||||||
onChanged: { translationX, width in
|
|
||||||
updateBackSwipe(with: translationX, containerWidth: width)
|
|
||||||
},
|
|
||||||
onEnded: { translationX, width, velocityX, didFinish in
|
|
||||||
finishBackSwipe(
|
|
||||||
translationX: translationX,
|
|
||||||
containerWidth: width,
|
|
||||||
velocityX: velocityX,
|
|
||||||
didFinish: didFinish
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
updatePhoneStackWidth(proxy.size.width)
|
updatePhoneStackWidth(proxy.size.width)
|
||||||
@@ -100,13 +85,8 @@ struct SybilPhoneShellView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
.animation(.easeOut(duration: 0.22), value: path.last)
|
.animation(.easeOut(duration: 0.22), value: route)
|
||||||
.onChange(of: path) { _, nextPath in
|
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
|
||||||
guard nextPath.isEmpty else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resetBackSwipe(animated: false)
|
|
||||||
}
|
|
||||||
.onChange(of: scenePhase) { _, nextPhase in
|
.onChange(of: scenePhase) { _, nextPhase in
|
||||||
switch nextPhase {
|
switch nextPhase {
|
||||||
case .background:
|
case .background:
|
||||||
@@ -120,8 +100,8 @@ struct SybilPhoneShellView: View {
|
|||||||
shouldRefreshOnForeground = false
|
shouldRefreshOnForeground = false
|
||||||
Task {
|
Task {
|
||||||
await viewModel.refreshAfterAppBecameActive(
|
await viewModel.refreshAfterAppBecameActive(
|
||||||
refreshCollections: path.isEmpty,
|
refreshCollections: isSidebarOverlayPresented,
|
||||||
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
|
refreshSelection: !isSidebarOverlayPresented && viewModel.hasRefreshableSelection
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case .inactive:
|
case .inactive:
|
||||||
@@ -133,16 +113,117 @@ struct SybilPhoneShellView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var phoneRootTopBar: some View {
|
private func phoneStack(width: CGFloat) -> some View {
|
||||||
HStack {
|
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)
|
SybilWordmark(size: 21)
|
||||||
Spacer()
|
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(.horizontal, 16)
|
||||||
.padding(.top, 10)
|
.padding(.top, 10)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
.background {
|
.background {
|
||||||
SybilTheme.panelGradient
|
SybilPhoneOverlayBlurBand(edge: .top)
|
||||||
.ignoresSafeArea(edges: .top)
|
.ignoresSafeArea(edges: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,62 +232,98 @@ struct SybilPhoneShellView: View {
|
|||||||
phoneStackWidth = max(width, 1)
|
phoneStackWidth = max(width, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func requestBack(animateNavigation: Bool = true) {
|
|
||||||
guard !path.isEmpty, !backSwipeIsCompleting else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if animateNavigation {
|
|
||||||
Task {
|
|
||||||
await completeBackSwipe(containerWidth: phoneStackWidth, releaseVelocityX: 0)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
popRoute(disablesAnimations: true)
|
|
||||||
resetBackSwipe(animated: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startNewChatFromDestination() {
|
private func startNewChatFromDestination() {
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
composerFocusRequest += 1
|
composerFocusRequest += 1
|
||||||
replaceTopRoute(with: .draftChat)
|
showRoute(.draftChat)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func replaceTopRoute(with route: PhoneRoute) {
|
private func showRoute(_ nextRoute: PhoneRoute) {
|
||||||
if path.isEmpty {
|
let update = {
|
||||||
|
route = nextRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSidebarOverlayPresented {
|
||||||
withAnimation(.easeOut(duration: 0.22)) {
|
withAnimation(.easeOut(duration: 0.22)) {
|
||||||
path = [route]
|
update()
|
||||||
|
isSidebarOverlayPresented = false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
path[path.index(before: path.endIndex)] = route
|
update()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetSidebarSwipe(animated: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func popRoute(disablesAnimations: Bool) {
|
private func showRouteAndClearSidebarHighlight(_ nextRoute: PhoneRoute) {
|
||||||
let pop = {
|
showRoute(nextRoute)
|
||||||
guard !path.isEmpty else {
|
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
|
return
|
||||||
}
|
}
|
||||||
_ = path.removeLast()
|
|
||||||
}
|
|
||||||
|
|
||||||
if disablesAnimations {
|
showRoute(PhoneRoute.from(selection: selection))
|
||||||
var transaction = Transaction()
|
openingSelectionRequestID = nil
|
||||||
transaction.disablesAnimations = true
|
clearSidebarHighlight(selection, after: .milliseconds(260))
|
||||||
withTransaction(transaction) {
|
|
||||||
pop()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
withAnimation(.easeOut(duration: 0.22)) {
|
|
||||||
pop()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func beginBackSwipe(containerWidth: CGFloat) {
|
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 = {
|
let update = {
|
||||||
backSwipeIsActive = true
|
phoneStackWidth = max(containerWidth, 1)
|
||||||
backSwipeHasLatched = false
|
sidebarSwipeIsActive = true
|
||||||
|
sidebarSwipeHasLatched = false
|
||||||
}
|
}
|
||||||
|
|
||||||
var transaction = Transaction()
|
var transaction = Transaction()
|
||||||
@@ -214,97 +331,79 @@ struct SybilPhoneShellView: View {
|
|||||||
withTransaction(transaction, update)
|
withTransaction(transaction, update)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateBackSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
|
private func updateSidebarSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
|
||||||
let nextOffset = BackSwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
|
let nextOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
|
||||||
let nextLatched = BackSwipeMetrics.isLatched(
|
let nextLatched = SidebarOverlaySwipeMetrics.isLatched(
|
||||||
offset: nextOffset,
|
offset: nextOffset,
|
||||||
width: containerWidth,
|
width: containerWidth,
|
||||||
isCurrentlyLatched: backSwipeHasLatched
|
isCurrentlyLatched: sidebarSwipeHasLatched
|
||||||
)
|
)
|
||||||
|
|
||||||
var transaction = Transaction()
|
var transaction = Transaction()
|
||||||
transaction.disablesAnimations = true
|
transaction.disablesAnimations = true
|
||||||
withTransaction(transaction) {
|
withTransaction(transaction) {
|
||||||
backSwipeOffset = nextOffset
|
phoneStackWidth = max(containerWidth, 1)
|
||||||
backSwipeHasLatched = nextLatched
|
sidebarSwipeOffset = nextOffset
|
||||||
|
sidebarSwipeHasLatched = nextLatched
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finishBackSwipe(
|
private func finishSidebarSwipe(
|
||||||
translationX: CGFloat,
|
translationX: CGFloat,
|
||||||
containerWidth: CGFloat,
|
containerWidth: CGFloat,
|
||||||
velocityX: CGFloat,
|
velocityX: CGFloat,
|
||||||
didFinish: Bool
|
didFinish: Bool
|
||||||
) {
|
) {
|
||||||
guard backSwipeIsActive else {
|
guard sidebarSwipeIsActive else {
|
||||||
resetBackSwipe(animated: false)
|
resetSidebarSwipe(animated: false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let finalOffset = BackSwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
|
let finalOffset = SidebarOverlaySwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
|
||||||
let finalLatched = BackSwipeMetrics.isLatched(
|
let finalLatched = SidebarOverlaySwipeMetrics.isLatched(
|
||||||
offset: finalOffset,
|
offset: finalOffset,
|
||||||
width: containerWidth,
|
width: containerWidth,
|
||||||
isCurrentlyLatched: backSwipeHasLatched
|
isCurrentlyLatched: sidebarSwipeHasLatched
|
||||||
)
|
)
|
||||||
updateBackSwipe(with: translationX, containerWidth: containerWidth)
|
updateSidebarSwipe(with: translationX, containerWidth: containerWidth)
|
||||||
|
|
||||||
if didFinish && BackSwipeMetrics.shouldComplete(
|
if didFinish && SidebarOverlaySwipeMetrics.shouldComplete(
|
||||||
offset: finalOffset,
|
offset: finalOffset,
|
||||||
velocityX: velocityX,
|
velocityX: velocityX,
|
||||||
width: containerWidth,
|
width: containerWidth,
|
||||||
isLatched: finalLatched
|
isLatched: finalLatched
|
||||||
) {
|
) {
|
||||||
Task {
|
completeSidebarSwipe()
|
||||||
await completeBackSwipe(containerWidth: containerWidth, releaseVelocityX: velocityX)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resetBackSwipe(animated: true, velocityX: velocityX)
|
resetSidebarSwipe(animated: true, velocityX: velocityX)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
private func completeSidebarSwipe() {
|
||||||
private func completeBackSwipe(containerWidth: CGFloat, releaseVelocityX: CGFloat) async {
|
guard !sidebarSwipeIsCompleting else {
|
||||||
guard !path.isEmpty else {
|
|
||||||
resetBackSwipe(animated: false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard !backSwipeIsCompleting else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
backSwipeIsCompleting = true
|
sidebarSwipeIsCompleting = true
|
||||||
let targetOffset = BackSwipeMetrics.completionTargetOffset(for: containerWidth)
|
withAnimation(.easeOut(duration: 0.18)) {
|
||||||
|
isSidebarOverlayPresented = true
|
||||||
withAnimation(
|
|
||||||
BackSwipeMetrics.springAnimation(
|
|
||||||
currentOffset: backSwipeOffset,
|
|
||||||
targetOffset: targetOffset,
|
|
||||||
velocityX: releaseVelocityX
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
backSwipeCompletionOffset = targetOffset - backSwipeOffset
|
|
||||||
}
|
}
|
||||||
|
resetSidebarSwipe(animated: false)
|
||||||
try? await Task.sleep(for: .milliseconds(BackSwipeMetrics.completionAnimationDelayMs))
|
|
||||||
popRoute(disablesAnimations: true)
|
|
||||||
resetBackSwipe(animated: false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetBackSwipe(animated: Bool, velocityX: CGFloat = 0) {
|
private func resetSidebarSwipe(animated: Bool, velocityX: CGFloat = 0) {
|
||||||
let currentOffset = backSwipeOffset + backSwipeCompletionOffset
|
let currentOffset = sidebarSwipeOffset
|
||||||
let reset = {
|
let reset = {
|
||||||
backSwipeOffset = 0
|
sidebarSwipeOffset = 0
|
||||||
backSwipeCompletionOffset = 0
|
sidebarSwipeIsActive = false
|
||||||
backSwipeIsActive = false
|
sidebarSwipeIsCompleting = false
|
||||||
backSwipeIsCompleting = false
|
sidebarSwipeHasLatched = false
|
||||||
backSwipeHasLatched = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if animated {
|
if animated {
|
||||||
withAnimation(
|
withAnimation(
|
||||||
BackSwipeMetrics.springAnimation(
|
SidebarOverlaySwipeMetrics.springAnimation(
|
||||||
currentOffset: currentOffset,
|
currentOffset: currentOffset,
|
||||||
targetOffset: 0,
|
targetOffset: 0,
|
||||||
velocityX: velocityX
|
velocityX: velocityX
|
||||||
@@ -318,31 +417,79 @@ struct SybilPhoneShellView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SybilPhoneSidebarRoot: View {
|
private enum SidebarOverlaySwipeMetrics {
|
||||||
@Bindable var viewModel: SybilViewModel
|
static func clampedOffset(for rawTranslation: CGFloat, width: CGFloat) -> CGFloat {
|
||||||
@Binding var path: [PhoneRoute]
|
BackSwipeMetrics.clampedOffset(for: rawTranslation, width: width)
|
||||||
@State private var openingSelection: SidebarSelection?
|
}
|
||||||
@State private var openingRequestID: UUID?
|
|
||||||
|
|
||||||
private var highlightedSelection: SidebarSelection? {
|
static func progress(for offset: CGFloat, width: CGFloat) -> CGFloat {
|
||||||
if let openingSelection {
|
BackSwipeMetrics.progress(for: offset, width: width)
|
||||||
return openingSelection
|
}
|
||||||
}
|
|
||||||
|
|
||||||
guard let route = path.last else {
|
static func isLatched(offset: CGFloat, width: CGFloat, isCurrentlyLatched: Bool = false) -> Bool {
|
||||||
return nil
|
BackSwipeMetrics.isLatched(offset: offset, width: width, isCurrentlyLatched: isCurrentlyLatched)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch route {
|
static func shouldComplete(offset: CGFloat, velocityX: CGFloat, width: CGFloat, isLatched: Bool) -> Bool {
|
||||||
case let .chat(chatID):
|
BackSwipeMetrics.shouldComplete(offset: offset, velocityX: velocityX, width: width, isLatched: isLatched)
|
||||||
return .chat(chatID)
|
}
|
||||||
case let .search(searchID):
|
|
||||||
return .search(searchID)
|
static func springAnimation(currentOffset: CGFloat, targetOffset: CGFloat, velocityX: CGFloat) -> Animation {
|
||||||
case .draftChat, .draftSearch, .settings:
|
BackSwipeMetrics.springAnimation(currentOffset: currentOffset, targetOffset: targetOffset, velocityX: velocityX)
|
||||||
return nil
|
}
|
||||||
|
|
||||||
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if let errorMessage = viewModel.errorMessage {
|
if let errorMessage = viewModel.errorMessage {
|
||||||
@@ -357,64 +504,15 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
.overlay(SybilTheme.border)
|
.overlay(SybilTheme.border)
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
SybilSidebarItemList(
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
viewModel: viewModel,
|
||||||
ProgressView()
|
isSelected: { item in
|
||||||
.tint(SybilTheme.primary)
|
highlightedSelection == item.selection
|
||||||
Text("Loading conversations…")
|
},
|
||||||
.font(.sybil(.footnote))
|
onSelect: { item in
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
onSelect(item.selection)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
)
|
||||||
.padding(16)
|
|
||||||
} else if viewModel.sidebarItems.isEmpty {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Image(systemName: "message.badge")
|
|
||||||
.font(.system(size: 20, weight: .medium))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
Text("Start a chat or run your first search.")
|
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding(16)
|
|
||||||
} else {
|
|
||||||
ScrollView {
|
|
||||||
LazyVStack(alignment: .leading, spacing: 0) {
|
|
||||||
ForEach(viewModel.sidebarItems) { item in
|
|
||||||
Button {
|
|
||||||
open(item.selection)
|
|
||||||
} label: {
|
|
||||||
VStack(spacing: 0.0) {
|
|
||||||
SybilPhoneSidebarRow(item: item)
|
|
||||||
Divider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(
|
|
||||||
SybilPhoneSidebarRowButtonStyle(
|
|
||||||
isHighlighted: highlightedSelection == item.selection
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.contextMenu {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteItem(item.selection)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.refreshable {
|
|
||||||
await viewModel.refreshVisibleContent(
|
|
||||||
refreshCollections: true,
|
|
||||||
refreshSelection: false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.background(SybilTheme.panelGradient)
|
.background(SybilTheme.panelGradient)
|
||||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
@@ -429,22 +527,20 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
toolbarIconButton(systemImage: "gearshape", accessibilityLabel: "Settings") {
|
||||||
clearOpeningSelection()
|
viewModel.openSettings()
|
||||||
showRoute(.settings)
|
onRoute(.settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
|
||||||
clearOpeningSelection()
|
|
||||||
viewModel.startNewSearch()
|
viewModel.startNewSearch()
|
||||||
showRoute(.draftSearch)
|
onRoute(.draftSearch)
|
||||||
}
|
}
|
||||||
|
|
||||||
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
|
||||||
clearOpeningSelection()
|
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
showRoute(.draftChat)
|
onRoute(.draftChat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 18)
|
.padding(.horizontal, 18)
|
||||||
@@ -480,121 +576,6 @@ private struct SybilPhoneSidebarRoot: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(accessibilityLabel)
|
.accessibilityLabel(accessibilityLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clearOpeningSelection() {
|
|
||||||
openingRequestID = nil
|
|
||||||
openingSelection = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showRoute(_ route: PhoneRoute) {
|
|
||||||
withAnimation(.easeOut(duration: 0.22)) {
|
|
||||||
path = [route]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func open(_ selection: SidebarSelection) {
|
|
||||||
guard openingSelection != selection else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let requestID = UUID()
|
|
||||||
openingRequestID = requestID
|
|
||||||
openingSelection = selection
|
|
||||||
Task {
|
|
||||||
await viewModel.selectForNavigation(selection)
|
|
||||||
guard openingRequestID == requestID else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showRoute(PhoneRoute.from(selection: selection))
|
|
||||||
openingRequestID = nil
|
|
||||||
openingSelection = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SybilPhoneSidebarRowIsActiveKey: EnvironmentKey {
|
|
||||||
static let defaultValue = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension EnvironmentValues {
|
|
||||||
var sybilPhoneSidebarRowIsActive: Bool {
|
|
||||||
get { self[SybilPhoneSidebarRowIsActiveKey.self] }
|
|
||||||
set { self[SybilPhoneSidebarRowIsActiveKey.self] = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SybilPhoneSidebarRowButtonStyle: ButtonStyle {
|
|
||||||
var isHighlighted: Bool
|
|
||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
configuration.label
|
|
||||||
.environment(\.sybilPhoneSidebarRowIsActive, isHighlighted || configuration.isPressed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct SybilPhoneSidebarRow: View {
|
|
||||||
@Environment(\.sybilPhoneSidebarRowIsActive) private var isHighlighted
|
|
||||||
var item: SidebarItem
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
let leadingWidth = 22.0
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: item.kind == .chat ? "message" : "globe")
|
|
||||||
.font(.system(size: 12, weight: .semibold))
|
|
||||||
.foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
|
|
||||||
.frame(width: leadingWidth, height: leadingWidth)
|
|
||||||
.background(
|
|
||||||
Rectangle()
|
|
||||||
.fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(item.title)
|
|
||||||
.font(.sybil(.subheadline, weight: .semibold))
|
|
||||||
.lineLimit(1)
|
|
||||||
.layoutPriority(1)
|
|
||||||
|
|
||||||
Spacer(minLength: 8)
|
|
||||||
|
|
||||||
if item.isRunning {
|
|
||||||
SybilSidebarActivityIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Spacer()
|
|
||||||
.frame(width: leadingWidth)
|
|
||||||
|
|
||||||
Text(item.updatedAt.sybilRelativeLabel)
|
|
||||||
.font(.sybil(.caption2))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
|
|
||||||
if let initiated = item.initiatedLabel {
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
Text(initiated)
|
|
||||||
.font(.sybil(.caption2))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
|
||||||
.lineLimit(1)
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundStyle(SybilTheme.text)
|
|
||||||
.padding(18.0)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(
|
|
||||||
Rectangle()
|
|
||||||
.fill(
|
|
||||||
isHighlighted
|
|
||||||
? SybilTheme.selectedRowGradient
|
|
||||||
: LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct SybilPhoneDestinationView: View {
|
private struct SybilPhoneDestinationView: View {
|
||||||
@@ -602,12 +583,15 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
@Binding var composerFocusRequest: Int
|
@Binding var composerFocusRequest: Int
|
||||||
let route: PhoneRoute
|
let route: PhoneRoute
|
||||||
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
let onRequestBack: (_ animateNavigation: Bool) -> Void
|
||||||
let onRequestNewChat: () -> Void
|
let onRequestNewChat: (() -> Void)?
|
||||||
|
let onShowSidebar: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SybilWorkspaceView(
|
SybilWorkspaceView(
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
composerFocusRequest: composerFocusRequest,
|
composerFocusRequest: composerFocusRequest,
|
||||||
|
navigationLeadingControl: .showSidebar,
|
||||||
|
onShowSidebar: onShowSidebar,
|
||||||
onRequestBack: onRequestBack,
|
onRequestBack: onRequestBack,
|
||||||
onRequestNewChat: onRequestNewChat
|
onRequestNewChat: onRequestNewChat
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,13 +4,6 @@ import SwiftUI
|
|||||||
struct SybilSidebarView: View {
|
struct SybilSidebarView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
|
||||||
private func iconName(for item: SidebarItem) -> String {
|
|
||||||
switch item.kind {
|
|
||||||
case .chat: return "message"
|
|
||||||
case .search: return "globe"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isSelected(_ item: SidebarItem) -> Bool {
|
private func isSelected(_ item: SidebarItem) -> Bool {
|
||||||
viewModel.draftKind == nil && viewModel.selectedItem == item.selection
|
viewModel.draftKind == nil && viewModel.selectedItem == item.selection
|
||||||
}
|
}
|
||||||
@@ -57,112 +50,13 @@ struct SybilSidebarView: View {
|
|||||||
.overlay(SybilTheme.border)
|
.overlay(SybilTheme.border)
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
SybilSidebarItemList(
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
viewModel: viewModel,
|
||||||
ProgressView()
|
isSelected: isSelected,
|
||||||
.tint(SybilTheme.primary)
|
onSelect: { item in
|
||||||
Text("Loading conversations…")
|
viewModel.select(item.selection)
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
)
|
||||||
.padding(16)
|
|
||||||
} else if viewModel.sidebarItems.isEmpty {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Image(systemName: "message.badge")
|
|
||||||
.font(.system(size: 20, weight: .medium))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
Text("Start a chat or run your first search.")
|
|
||||||
.font(.sybil(.footnote))
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
.padding(16)
|
|
||||||
} else {
|
|
||||||
ScrollView {
|
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
|
||||||
ForEach(viewModel.sidebarItems) { item in
|
|
||||||
Button {
|
|
||||||
viewModel.select(item.selection)
|
|
||||||
} label: {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: iconName(for: item))
|
|
||||||
.font(.system(size: 12, weight: .semibold))
|
|
||||||
.foregroundStyle(isSelected(item) ? SybilTheme.accent : SybilTheme.textMuted)
|
|
||||||
.frame(width: 22, height: 22)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 7)
|
|
||||||
.fill(isSelected(item) ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 7)
|
|
||||||
.stroke(isSelected(item) ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(item.title)
|
|
||||||
.font(.sybil(.subheadline, weight: .semibold))
|
|
||||||
.lineLimit(1)
|
|
||||||
.layoutPriority(1)
|
|
||||||
|
|
||||||
Spacer(minLength: 8)
|
|
||||||
|
|
||||||
if item.isRunning {
|
|
||||||
SybilSidebarActivityIndicator()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Text(item.updatedAt.sybilRelativeLabel)
|
|
||||||
.font(.sybil(.caption2))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
|
|
||||||
if let initiated = item.initiatedLabel {
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
Text(initiated)
|
|
||||||
.font(.sybil(.caption2))
|
|
||||||
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
|
||||||
.lineLimit(1)
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.foregroundStyle(SybilTheme.text)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(isSelected(item) ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.stroke(isSelected(item) ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.contextMenu {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
Task {
|
|
||||||
await viewModel.deleteItem(item.selection)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(10)
|
|
||||||
}
|
|
||||||
.refreshable {
|
|
||||||
await viewModel.refreshVisibleContent(
|
|
||||||
refreshCollections: true,
|
|
||||||
refreshSelection: false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
.background(SybilTheme.panelGradient)
|
.background(SybilTheme.panelGradient)
|
||||||
@@ -213,6 +107,142 @@ struct SybilSidebarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SybilSidebarItemList: View {
|
||||||
|
@Bindable var viewModel: SybilViewModel
|
||||||
|
var isSelected: (SidebarItem) -> Bool
|
||||||
|
var onSelect: (SidebarItem) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.tint(SybilTheme.primary)
|
||||||
|
Text("Loading conversations…")
|
||||||
|
.font(.sybil(.footnote))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.padding(16)
|
||||||
|
} else if viewModel.sidebarItems.isEmpty {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Image(systemName: "message.badge")
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
Text("Start a chat or run your first search.")
|
||||||
|
.font(.sybil(.footnote))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding(16)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(viewModel.sidebarItems) { item in
|
||||||
|
Button {
|
||||||
|
onSelect(item)
|
||||||
|
} label: {
|
||||||
|
SybilSidebarRow(item: item, isSelected: isSelected(item))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await viewModel.deleteItem(item.selection)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.refreshVisibleContent(
|
||||||
|
refreshCollections: true,
|
||||||
|
refreshSelection: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SybilSidebarRow: View {
|
||||||
|
var item: SidebarItem
|
||||||
|
var isSelected: Bool
|
||||||
|
|
||||||
|
private var isHighlighted: Bool {
|
||||||
|
isSelected
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconName: String {
|
||||||
|
switch item.kind {
|
||||||
|
case .chat: return "message"
|
||||||
|
case .search: return "globe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: iconName)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 7)
|
||||||
|
.stroke(isHighlighted ? SybilTheme.accent.opacity(0.36) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(item.title)
|
||||||
|
.font(.sybil(.subheadline, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
.layoutPriority(1)
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
if item.isRunning {
|
||||||
|
SybilSidebarActivityIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(item.updatedAt.sybilRelativeLabel)
|
||||||
|
.font(.sybil(.caption2))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
|
|
||||||
|
if let initiated = item.initiatedLabel {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text(initiated)
|
||||||
|
.font(.sybil(.caption2))
|
||||||
|
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
||||||
|
.lineLimit(1)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(SybilTheme.text)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(isHighlighted ? SybilTheme.selectedRowGradient : LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(isHighlighted ? SybilTheme.primary.opacity(0.55) : SybilTheme.border.opacity(0.72), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct SybilSidebarActivityIndicator: View {
|
struct SybilSidebarActivityIndicator: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var showsCustomWorkspaceNavigation: Bool {
|
private var showsCustomWorkspaceNavigation: Bool {
|
||||||
usesCustomWorkspaceNavigation && (!isSettingsSelected || navigationLeadingControl == .back)
|
usesCustomWorkspaceNavigation && (!isSettingsSelected || navigationLeadingControl != .hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var transcriptScrollContextID: String {
|
private var transcriptScrollContextID: String {
|
||||||
|
|||||||
Reference in New Issue
Block a user