ios: redesign top navbar
This commit is contained in:
@@ -5,6 +5,7 @@ struct SybilChatTranscriptView: View {
|
|||||||
var messages: [Message]
|
var messages: [Message]
|
||||||
var isLoading: Bool
|
var isLoading: Bool
|
||||||
var isSending: Bool
|
var isSending: Bool
|
||||||
|
var topContentInset: CGFloat = 0
|
||||||
@State private var hasHandledInitialTranscriptScroll = false
|
@State private var hasHandledInitialTranscriptScroll = false
|
||||||
|
|
||||||
private var hasPendingAssistant: Bool {
|
private var hasPendingAssistant: Bool {
|
||||||
@@ -48,7 +49,8 @@ struct SybilChatTranscriptView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 18)
|
.padding(.top, 18 + topContentInset)
|
||||||
|
.padding(.bottom, 18)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
|||||||
@@ -262,7 +262,11 @@ private struct SybilPhoneDestinationView: View {
|
|||||||
let route: PhoneRoute
|
let route: PhoneRoute
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SybilWorkspaceView(viewModel: viewModel, composerFocusRequest: composerFocusRequest) {
|
SybilWorkspaceView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
composerFocusRequest: composerFocusRequest,
|
||||||
|
usesCustomChatNavigation: route.isChatTranscript
|
||||||
|
) {
|
||||||
viewModel.startNewChat()
|
viewModel.startNewChat()
|
||||||
composerFocusRequest += 1
|
composerFocusRequest += 1
|
||||||
if path.isEmpty {
|
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 {
|
struct SybilWorkspaceView: View {
|
||||||
@Bindable var viewModel: SybilViewModel
|
@Bindable var viewModel: SybilViewModel
|
||||||
var composerFocusRequest: Int = 0
|
var composerFocusRequest: Int = 0
|
||||||
|
var usesCustomChatNavigation: Bool = false
|
||||||
var onRequestNewChat: (() -> Void)? = nil
|
var onRequestNewChat: (() -> Void)? = nil
|
||||||
@FocusState private var composerFocused: Bool
|
@FocusState private var composerFocused: Bool
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var isShowingAttachmentOptions = false
|
@State private var isShowingAttachmentOptions = false
|
||||||
@State private var isShowingFileImporter = false
|
@State private var isShowingFileImporter = false
|
||||||
@State private var isShowingPhotoPicker = false
|
@State private var isShowingPhotoPicker = false
|
||||||
@@ -23,6 +25,8 @@ struct SybilWorkspaceView: View {
|
|||||||
@State private var newChatSwipeDidTriggerHaptic = false
|
@State private var newChatSwipeDidTriggerHaptic = false
|
||||||
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
|
@State private var newChatSwipeFeedbackGenerator: UIImpactFeedbackGenerator?
|
||||||
|
|
||||||
|
private let customChatNavigationContentInset: CGFloat = 88
|
||||||
|
|
||||||
private var isSettingsSelected: Bool {
|
private var isSettingsSelected: Bool {
|
||||||
if case .settings = viewModel.selectedItem {
|
if case .settings = viewModel.selectedItem {
|
||||||
return true
|
return true
|
||||||
@@ -34,6 +38,10 @@ struct SybilWorkspaceView: View {
|
|||||||
viewModel.errorMessage != nil
|
viewModel.errorMessage != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var showsCustomChatNavigation: Bool {
|
||||||
|
usesCustomChatNavigation && !isSettingsSelected && !viewModel.isSearchMode
|
||||||
|
}
|
||||||
|
|
||||||
private var transcriptScrollContextID: String {
|
private var transcriptScrollContextID: String {
|
||||||
if viewModel.draftKind == .chat {
|
if viewModel.draftKind == .chat {
|
||||||
return "draft-chat"
|
return "draft-chat"
|
||||||
@@ -84,16 +92,17 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
.offset(x: newChatSwipeCompletionOffset)
|
.offset(x: newChatSwipeCompletionOffset)
|
||||||
.background(SybilTheme.background)
|
.background(SybilTheme.background)
|
||||||
.navigationTitle(viewModel.selectedTitle)
|
.navigationTitle(showsCustomChatNavigation ? "" : viewModel.selectedTitle)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbarRole(.editor)
|
.toolbarRole(.editor)
|
||||||
|
.toolbar(showsCustomChatNavigation ? .hidden : .visible, for: .navigationBar)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if !isSettingsSelected {
|
if !isSettingsSelected && !showsCustomChatNavigation {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
if viewModel.isSearchMode {
|
if viewModel.isSearchMode {
|
||||||
searchModeChip
|
searchModeChip
|
||||||
} else {
|
} else {
|
||||||
providerModelMenu
|
providerModelToolbarMenu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,6 +120,16 @@ struct SybilWorkspaceView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var workspaceContent: some View {
|
private var workspaceContent: some View {
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
workspaceContentStack
|
||||||
|
|
||||||
|
if showsCustomChatNavigation {
|
||||||
|
customChatNavigationBar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var workspaceContentStack: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if showsHeader {
|
if showsHeader {
|
||||||
header
|
header
|
||||||
@@ -137,7 +156,8 @@ struct SybilWorkspaceView: View {
|
|||||||
SybilChatTranscriptView(
|
SybilChatTranscriptView(
|
||||||
messages: viewModel.displayedMessages,
|
messages: viewModel.displayedMessages,
|
||||||
isLoading: viewModel.isLoadingSelection,
|
isLoading: viewModel.isLoadingSelection,
|
||||||
isSending: viewModel.isSending
|
isSending: viewModel.isSending,
|
||||||
|
topContentInset: showsCustomChatNavigation ? customChatNavigationContentInset : 0
|
||||||
)
|
)
|
||||||
.id(transcriptScrollContextID)
|
.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) {
|
private func beginNewChatSwipe(containerWidth: CGFloat) {
|
||||||
let update = {
|
let update = {
|
||||||
newChatSwipeContainerWidth = max(containerWidth, 1)
|
newChatSwipeContainerWidth = max(containerWidth, 1)
|
||||||
@@ -292,34 +341,8 @@ struct SybilWorkspaceView: View {
|
|||||||
.background(SybilTheme.panelGradient.opacity(0.58))
|
.background(SybilTheme.panelGradient.opacity(0.58))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var providerModelMenu: some View {
|
private var providerModelToolbarMenu: some View {
|
||||||
Menu {
|
providerModelMenu {
|
||||||
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: {
|
|
||||||
Image(systemName: "ellipsis")
|
Image(systemName: "ellipsis")
|
||||||
.font(.system(size: 18, weight: .semibold))
|
.font(.system(size: 18, weight: .semibold))
|
||||||
.foregroundStyle(SybilTheme.text)
|
.foregroundStyle(SybilTheme.text)
|
||||||
@@ -333,9 +356,52 @@ struct SybilWorkspaceView: View {
|
|||||||
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
.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")
|
.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 {
|
private var searchModeChip: some View {
|
||||||
Label("Search", systemImage: "globe")
|
Label("Search", systemImage: "globe")
|
||||||
.font(.sybil(.caption, weight: .medium))
|
.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 {
|
private struct NewChatSwipeBackdrop: View {
|
||||||
var progress: CGFloat
|
var progress: CGFloat
|
||||||
var hasLatched: Bool
|
var hasLatched: Bool
|
||||||
|
|||||||
Reference in New Issue
Block a user