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

641 lines
22 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
@State private var path: [PhoneRoute] = []
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
@State private var backSwipeOffset: CGFloat = 0
@State private var backSwipeCompletionOffset: CGFloat = 0
@State private var backSwipeIsActive = false
@State private var backSwipeIsCompleting = false
@State private var backSwipeHasLatched = false
private var canRecognizeBackSwipe: Bool {
!path.isEmpty && !backSwipeIsCompleting
}
private var backSwipeVisualOffset: CGFloat {
backSwipeOffset + backSwipeCompletionOffset
}
2026-02-20 00:09:02 -08:00
var body: some View {
GeometryReader { proxy in
ZStack(alignment: .topLeading) {
SybilPhoneSidebarRoot(viewModel: viewModel, path: $path)
.safeAreaInset(edge: .top, spacing: 0) {
phoneRootTopBar
2026-05-02 16:23:00 -07:00
}
.zIndex(0)
if let route = path.last {
2026-05-02 22:46:25 -07:00
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
2026-05-02 22:46:25 -07:00
)
.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)
}
2026-02-20 00:09:02 -08:00
}
}
.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)
.animation(.easeOut(duration: 0.22), value: path.last)
.onChange(of: path) { _, nextPath in
guard nextPath.isEmpty else {
return
}
resetBackSwipe(animated: false)
}
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-02 22:18:33 -07:00
refreshCollections: path.isEmpty,
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
)
}
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
}
private var phoneRootTopBar: some View {
HStack {
SybilWordmark(size: 21)
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 12)
.background {
SybilTheme.panelGradient
.ignoresSafeArea(edges: .top)
}
}
private func updatePhoneStackWidth(_ width: CGFloat) {
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() {
viewModel.startNewChat()
composerFocusRequest += 1
replaceTopRoute(with: .draftChat)
}
private func replaceTopRoute(with route: PhoneRoute) {
if path.isEmpty {
withAnimation(.easeOut(duration: 0.22)) {
path = [route]
}
} else {
path[path.index(before: path.endIndex)] = route
}
}
private func popRoute(disablesAnimations: Bool) {
let pop = {
guard !path.isEmpty else {
return
}
_ = path.removeLast()
}
if disablesAnimations {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
pop()
}
} else {
withAnimation(.easeOut(duration: 0.22)) {
pop()
}
}
}
private func beginBackSwipe(containerWidth: CGFloat) {
let update = {
backSwipeIsActive = true
backSwipeHasLatched = false
}
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction, update)
}
private func updateBackSwipe(with rawTranslation: CGFloat, containerWidth: CGFloat) {
let nextOffset = BackSwipeMetrics.clampedOffset(for: rawTranslation, width: containerWidth)
let nextLatched = BackSwipeMetrics.isLatched(
offset: nextOffset,
width: containerWidth,
isCurrentlyLatched: backSwipeHasLatched
)
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
backSwipeOffset = nextOffset
backSwipeHasLatched = nextLatched
}
}
private func finishBackSwipe(
translationX: CGFloat,
containerWidth: CGFloat,
velocityX: CGFloat,
didFinish: Bool
) {
guard backSwipeIsActive else {
resetBackSwipe(animated: false)
return
}
let finalOffset = BackSwipeMetrics.clampedOffset(for: translationX, width: containerWidth)
let finalLatched = BackSwipeMetrics.isLatched(
offset: finalOffset,
width: containerWidth,
isCurrentlyLatched: backSwipeHasLatched
)
updateBackSwipe(with: translationX, containerWidth: containerWidth)
if didFinish && BackSwipeMetrics.shouldComplete(
offset: finalOffset,
velocityX: velocityX,
width: containerWidth,
isLatched: finalLatched
) {
Task {
await completeBackSwipe(containerWidth: containerWidth, releaseVelocityX: velocityX)
}
return
}
resetBackSwipe(animated: true, velocityX: velocityX)
}
@MainActor
private func completeBackSwipe(containerWidth: CGFloat, releaseVelocityX: CGFloat) async {
guard !path.isEmpty else {
resetBackSwipe(animated: false)
return
}
guard !backSwipeIsCompleting else {
return
}
backSwipeIsCompleting = true
let targetOffset = BackSwipeMetrics.completionTargetOffset(for: containerWidth)
withAnimation(
BackSwipeMetrics.springAnimation(
currentOffset: backSwipeOffset,
targetOffset: targetOffset,
velocityX: releaseVelocityX
)
) {
backSwipeCompletionOffset = targetOffset - backSwipeOffset
}
try? await Task.sleep(for: .milliseconds(BackSwipeMetrics.completionAnimationDelayMs))
popRoute(disablesAnimations: true)
resetBackSwipe(animated: false)
}
private func resetBackSwipe(animated: Bool, velocityX: CGFloat = 0) {
let currentOffset = backSwipeOffset + backSwipeCompletionOffset
let reset = {
backSwipeOffset = 0
backSwipeCompletionOffset = 0
backSwipeIsActive = false
backSwipeIsCompleting = false
backSwipeHasLatched = false
}
if animated {
withAnimation(
BackSwipeMetrics.springAnimation(
currentOffset: currentOffset,
targetOffset: 0,
velocityX: velocityX
)
) {
reset()
}
} else {
reset()
}
}
2026-02-20 00:09:02 -08:00
}
private struct SybilPhoneSidebarRoot: View {
@Bindable var viewModel: SybilViewModel
@Binding var path: [PhoneRoute]
@State private var openingSelection: SidebarSelection?
@State private var openingRequestID: UUID?
private var highlightedSelection: SidebarSelection? {
if let openingSelection {
return openingSelection
}
guard let route = path.last else {
return nil
}
switch route {
case let .chat(chatID):
return .chat(chatID)
case let .search(searchID):
return .search(searchID)
case .draftChat, .draftSearch, .settings:
return nil
}
}
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)
}
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ProgressView()
.tint(SybilTheme.primary)
Text("Loading conversations…")
2026-05-02 16:23:00 -07:00
.font(.sybil(.footnote))
2026-02-20 00:09:02 -08:00
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(16)
} else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) {
Image(systemName: "message.badge")
2026-05-02 16:23:00 -07:00
.font(.system(size: 20, weight: .medium))
2026-02-20 00:09:02 -08:00
.foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.")
2026-05-02 16:23:00 -07:00
.font(.sybil(.footnote))
2026-02-20 00:09:02 -08:00
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else {
ScrollView {
2026-05-03 22:11:29 -07:00
LazyVStack(alignment: .leading, spacing: 0) {
2026-02-20 00:09:02 -08:00
ForEach(viewModel.sidebarItems) { item in
Button {
open(item.selection)
} label: {
2026-05-03 22:11:29 -07:00
VStack(spacing: 0.0) {
SybilPhoneSidebarRow(item: item)
Divider()
}
2026-02-20 00:09:02 -08:00
}
.buttonStyle(
SybilPhoneSidebarRowButtonStyle(
isHighlighted: highlightedSelection == item.selection
)
)
2026-02-20 00:09:02 -08:00
.contextMenu {
Button(role: .destructive) {
Task {
await viewModel.deleteItem(item.selection)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
}
.refreshable {
await viewModel.refreshVisibleContent(
refreshCollections: true,
refreshSelection: false
)
}
2026-02-20 00:09:02 -08: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") {
clearOpeningSelection()
showRoute(.settings)
2026-05-02 16:23:00 -07:00
}
Spacer()
toolbarIconButton(systemImage: "magnifyingglass", accessibilityLabel: "New search") {
clearOpeningSelection()
2026-05-02 16:23:00 -07:00
viewModel.startNewSearch()
showRoute(.draftSearch)
2026-05-02 16:23:00 -07:00
}
toolbarIconButton(systemImage: "plus", accessibilityLabel: "New chat", isPrimary: true) {
clearOpeningSelection()
2026-05-02 16:23:00 -07:00
viewModel.startNewChat()
showRoute(.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
}
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)
}
2026-02-20 00:09:02 -08:00
}
private struct SybilPhoneSidebarRow: View {
@Environment(\.sybilPhoneSidebarRowIsActive) private var isHighlighted
2026-02-20 00:09:02 -08:00
var item: SidebarItem
var body: some View {
2026-05-03 22:11:29 -07:00
let leadingWidth = 22.0
VStack(alignment: .leading, spacing: 8) {
2026-02-20 00:09:02 -08:00
HStack(spacing: 8) {
Image(systemName: item.kind == .chat ? "message" : "globe")
2026-05-02 16:23:00 -07:00
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(isHighlighted ? SybilTheme.accent : SybilTheme.textMuted)
2026-05-03 22:11:29 -07:00
.frame(width: leadingWidth, height: leadingWidth)
2026-05-02 16:23:00 -07:00
.background(
2026-05-03 22:11:29 -07:00
Rectangle()
.fill(isHighlighted ? SybilTheme.accent.opacity(0.12) : SybilTheme.surface.opacity(0.72))
2026-05-03 22:11:29 -07:00
2026-05-02 16:23:00 -07:00
)
2026-02-20 00:09:02 -08:00
Text(item.title)
2026-05-02 16:23:00 -07:00
.font(.sybil(.subheadline, weight: .semibold))
2026-02-20 00:09:02 -08:00
.lineLimit(1)
2026-05-04 20:14:16 -07:00
.layoutPriority(1)
Spacer(minLength: 8)
if item.isRunning {
SybilSidebarActivityIndicator()
}
2026-02-20 00:09:02 -08:00
}
HStack(spacing: 8) {
2026-05-03 22:11:29 -07:00
Spacer()
.frame(width: leadingWidth)
2026-02-20 00:09:02 -08:00
Text(item.updatedAt.sybilRelativeLabel)
2026-05-02 16:23:00 -07:00
.font(.sybil(.caption2))
2026-02-20 00:09:02 -08:00
.foregroundStyle(SybilTheme.textMuted)
if let initiated = item.initiatedLabel {
Spacer(minLength: 0)
Text(initiated)
2026-05-02 16:23:00 -07:00
.font(.sybil(.caption2))
2026-02-20 00:09:02 -08:00
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineLimit(1)
2026-05-02 16:23:00 -07:00
.multilineTextAlignment(.trailing)
.frame(maxWidth: .infinity, alignment: .trailing)
2026-02-20 00:09:02 -08:00
}
}
}
.foregroundStyle(SybilTheme.text)
2026-05-03 22:11:29 -07:00
.padding(18.0)
2026-02-20 00:09:02 -08:00
.frame(maxWidth: .infinity, alignment: .leading)
.background(
2026-05-03 22:11:29 -07:00
Rectangle()
.fill(
isHighlighted
? SybilTheme.selectedRowGradient
: LinearGradient(colors: [SybilTheme.surface.opacity(0.56), SybilTheme.surface.opacity(0.36)], startPoint: .topLeading, endPoint: .bottomTrailing)
)
2026-02-20 00:09:02 -08:00
)
2026-05-03 22:11:29 -07: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
let onRequestNewChat: () -> 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,
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()
}
}
}