ios: redesign top navbar
This commit is contained in:
@@ -5,6 +5,7 @@ struct SybilChatTranscriptView: View {
|
||||
var messages: [Message]
|
||||
var isLoading: Bool
|
||||
var isSending: Bool
|
||||
var topContentInset: CGFloat = 0
|
||||
@State private var hasHandledInitialTranscriptScroll = false
|
||||
|
||||
private var hasPendingAssistant: Bool {
|
||||
@@ -48,7 +49,8 @@ struct SybilChatTranscriptView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 18)
|
||||
.padding(.top, 18 + topContentInset)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
|
||||
@@ -262,7 +262,11 @@ private struct SybilPhoneDestinationView: View {
|
||||
let route: PhoneRoute
|
||||
|
||||
var body: some View {
|
||||
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
||||
SybilWorkspaceView(
|
||||
viewModel: viewModel,
|
||||
composerFocusRequest: composerFocusRequest,
|
||||
usesCustomChatNavigation: route.isChatTranscript
|
||||
) {
|
||||
viewModel.startNewChat()
|
||||
composerFocusRequest += 1
|
||||
if path.isEmpty {
|
||||
@@ -293,3 +297,14 @@ private struct SybilPhoneDestinationView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension PhoneRoute {
|
||||
var isChatTranscript: Bool {
|
||||
switch self {
|
||||
case .chat, .draftChat:
|
||||
return true
|
||||
case .search, .draftSearch, .settings:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ import UIKit
|
||||
struct SybilWorkspaceView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
var composerFocusRequest: Int = 0
|
||||
var usesCustomChatNavigation: Bool = false
|
||||
var onRequestNewChat: (() -> Void)? = nil
|
||||
@FocusState private var composerFocused: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var isShowingAttachmentOptions = false
|
||||
@State private var isShowingFileImporter = false
|
||||
@State private var isShowingPhotoPicker = false
|
||||
@@ -23,6 +25,8 @@ struct SybilWorkspaceView: View {
|
||||
@State private var newChatSwipeDidTriggerHaptic = false
|
||||
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
|
||||
|
||||
private let customChatNavigationContentInset: CGFloat = 88
|
||||
|
||||
private var isSettingsSelected: Bool {
|
||||
if case .settings = viewModel.selectedItem {
|
||||
return true
|
||||
@@ -34,6 +38,10 @@ struct SybilWorkspaceView: View {
|
||||
viewModel.errorMessage != nil
|
||||
}
|
||||
|
||||
private var showsCustomChatNavigation: Bool {
|
||||
usesCustomChatNavigation && !isSettingsSelected && !viewModel.isSearchMode
|
||||
}
|
||||
|
||||
private var transcriptScrollContextID: String {
|
||||
if viewModel.draftKind == .chat {
|
||||
return "draft-chat"
|
||||
@@ -84,16 +92,17 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
.offset(x: newChatSwipeCompletionOffset)
|
||||
.background(SybilTheme.background)
|
||||
.navigationTitle(viewModel.selectedTitle)
|
||||
.navigationTitle(showsCustomChatNavigation ? "" : viewModel.selectedTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarRole(.editor)
|
||||
.toolbar(showsCustomChatNavigation ? .hidden : .visible, for: .navigationBar)
|
||||
.toolbar {
|
||||
if !isSettingsSelected {
|
||||
if !isSettingsSelected && !showsCustomChatNavigation {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if viewModel.isSearchMode {
|
||||
searchModeChip
|
||||
} else {
|
||||
providerModelMenu
|
||||
providerModelToolbarMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +120,16 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
|
||||
private var workspaceContent: some View {
|
||||
ZStack(alignment: .top) {
|
||||
workspaceContentStack
|
||||
|
||||
if showsCustomChatNavigation {
|
||||
customChatNavigationBar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var workspaceContentStack: some View {
|
||||
VStack(spacing: 0) {
|
||||
if showsHeader {
|
||||
header
|
||||
@@ -137,7 +156,8 @@ struct SybilWorkspaceView: View {
|
||||
SybilChatTranscriptView(
|
||||
messages: viewModel.displayedMessages,
|
||||
isLoading: viewModel.isLoadingSelection,
|
||||
isSending: viewModel.isSending
|
||||
isSending: viewModel.isSending,
|
||||
topContentInset: showsCustomChatNavigation ? customChatNavigationContentInset : 0
|
||||
)
|
||||
.id(transcriptScrollContextID)
|
||||
}
|
||||
@@ -170,6 +190,35 @@ struct SybilWorkspaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var customChatNavigationBar: some View {
|
||||
HStack(spacing: 14) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
SybilNavigationIcon(systemImage: "chevron.left")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Back")
|
||||
|
||||
Text(viewModel.selectedTitle)
|
||||
.font(.sybil(size: 16, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
providerModelNavigationMenu
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 34)
|
||||
.background(alignment: .top) {
|
||||
SybilNavigationFadeBackground()
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func beginNewChatSwipe(containerWidth: CGFloat) {
|
||||
let update = {
|
||||
newChatSwipeContainerWidth = max(containerWidth, 1)
|
||||
@@ -292,34 +341,8 @@ struct SybilWorkspaceView: View {
|
||||
.background(SybilTheme.panelGradient.opacity(0.58))
|
||||
}
|
||||
|
||||
private var providerModelMenu: some View {
|
||||
Menu {
|
||||
Text("\(viewModel.provider.displayName) • \(viewModel.model)")
|
||||
.font(.sybil(.caption))
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(Provider.allCases, id: \.self) { candidate in
|
||||
Menu(candidate.displayName) {
|
||||
let models = viewModel.modelOptions(for: candidate)
|
||||
if models.isEmpty {
|
||||
Text("No models")
|
||||
} else {
|
||||
ForEach(models, id: \.self) { candidateModel in
|
||||
Button {
|
||||
viewModel.setProvider(candidate, model: candidateModel)
|
||||
} label: {
|
||||
if viewModel.provider == candidate && viewModel.model == candidateModel {
|
||||
Label(candidateModel, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(candidateModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
private var providerModelToolbarMenu: some View {
|
||||
providerModelMenu {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
@@ -333,9 +356,52 @@ struct SybilWorkspaceView: View {
|
||||
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var providerModelNavigationMenu: some View {
|
||||
providerModelMenu {
|
||||
SybilNavigationIcon(systemImage: "ellipsis")
|
||||
}
|
||||
}
|
||||
|
||||
private func providerModelMenu<Label: View>(@ViewBuilder label: @escaping () -> Label) -> some View {
|
||||
Menu {
|
||||
providerModelMenuItems
|
||||
} label: {
|
||||
label()
|
||||
}
|
||||
.accessibilityLabel("Provider and model")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var providerModelMenuItems: some View {
|
||||
Text("\(viewModel.provider.displayName) • \(viewModel.model)")
|
||||
.font(.sybil(.caption))
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(Provider.allCases, id: \.self) { candidate in
|
||||
Menu(candidate.displayName) {
|
||||
let models = viewModel.modelOptions(for: candidate)
|
||||
if models.isEmpty {
|
||||
Text("No models")
|
||||
} else {
|
||||
ForEach(models, id: \.self) { candidateModel in
|
||||
Button {
|
||||
viewModel.setProvider(candidate, model: candidateModel)
|
||||
} label: {
|
||||
if viewModel.provider == candidate && viewModel.model == candidateModel {
|
||||
Label(candidateModel, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(candidateModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var searchModeChip: some View {
|
||||
Label("Search", systemImage: "globe")
|
||||
.font(.sybil(.caption, weight: .medium))
|
||||
@@ -868,6 +934,52 @@ private extension UIView {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilNavigationIcon: View {
|
||||
var systemImage: String
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 21, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(SybilTheme.text)
|
||||
.frame(width: 46, height: 46)
|
||||
.contentShape(Rectangle())
|
||||
.shadow(color: SybilTheme.primary.opacity(0.34), radius: 12, x: 0, y: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SybilNavigationFadeBackground: View {
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
SybilTheme.background.opacity(1.0),
|
||||
SybilTheme.background.opacity(0.90),
|
||||
SybilTheme.background.opacity(0.80),
|
||||
SybilTheme.background.opacity(0.80),
|
||||
SybilTheme.background.opacity(0.28),
|
||||
Color.clear
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
||||
RadialGradient(
|
||||
colors: [
|
||||
SybilTheme.primary.opacity(0.36),
|
||||
SybilTheme.primary.opacity(0.10),
|
||||
Color.clear
|
||||
],
|
||||
center: .topLeading,
|
||||
startRadius: 6,
|
||||
endRadius: 210
|
||||
)
|
||||
.blendMode(.screen)
|
||||
.offset(x: -44, y: -46)
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewChatSwipeBackdrop: View {
|
||||
var progress: CGFloat
|
||||
var hasLatched: Bool
|
||||
|
||||
Reference in New Issue
Block a user