Тулбар ChatDetail по Figma: capsule back-кнопка, аватар 44×44, padding и размеры
This commit is contained in:
@@ -96,11 +96,14 @@ private extension ChatListSearchContent {
|
||||
private extension ChatListSearchContent {
|
||||
@ViewBuilder
|
||||
var recentSearchesSection: some View {
|
||||
if viewModel.recentSearches.isEmpty {
|
||||
searchPlaceholder
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// Horizontal scrollable favorite contacts row
|
||||
FavoriteContactsRowSearch(onOpenDialog: onOpenDialog)
|
||||
|
||||
if viewModel.recentSearches.isEmpty {
|
||||
searchPlaceholderInline
|
||||
} else {
|
||||
HStack {
|
||||
Text("RECENT")
|
||||
.font(.system(size: 13))
|
||||
@@ -120,30 +123,33 @@ private extension ChatListSearchContent {
|
||||
recentRow(recent)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer().frame(height: 120)
|
||||
}
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
}
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
}
|
||||
|
||||
var searchPlaceholder: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
var searchPlaceholderInline: some View {
|
||||
VStack(spacing: 16) {
|
||||
LottieView(
|
||||
animationName: "search",
|
||||
animationSpeed: 1.0
|
||||
)
|
||||
.frame(width: 120, height: 120)
|
||||
.padding(.top, 60)
|
||||
|
||||
Text("Search for users")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
Text("Find people by username or public key")
|
||||
.font(.system(size: 14))
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
|
||||
Text("Find people by username or public key")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
func recentRow(_ user: RecentSearch) -> some View {
|
||||
@@ -157,22 +163,10 @@ private extension ChatListSearchContent {
|
||||
onSelectRecent(user.username.isEmpty ? user.publicKey : user.username)
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
AvatarView(
|
||||
initials: initials, colorIndex: colorIdx,
|
||||
size: 42, isSavedMessages: isSelf
|
||||
)
|
||||
Button {
|
||||
viewModel.removeRecentSearch(publicKey: user.publicKey)
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 18, height: 18)
|
||||
.background(Circle().fill(RosettaColors.figmaBlue))
|
||||
}
|
||||
.offset(x: 4, y: -4)
|
||||
}
|
||||
AvatarView(
|
||||
initials: initials, colorIndex: colorIdx,
|
||||
size: 42, isSavedMessages: isSelf
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(isSelf ? "Saved Messages" : (
|
||||
@@ -254,3 +248,48 @@ private extension ChatListSearchContent {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Favorite Contacts Row (observation-isolated)
|
||||
|
||||
/// Horizontal scrollable avatar row for active search state.
|
||||
/// Isolated child view so that `DialogRepository.shared.sortedDialogs` observation
|
||||
/// does NOT propagate to `ChatListSearchContent`'s parent.
|
||||
private struct FavoriteContactsRowSearch: View {
|
||||
var onOpenDialog: (ChatRoute) -> Void
|
||||
|
||||
var body: some View {
|
||||
let dialogs = DialogRepository.shared.sortedDialogs.prefix(10)
|
||||
if !dialogs.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(Array(dialogs), id: \.id) { dialog in
|
||||
Button {
|
||||
onOpenDialog(ChatRoute(dialog: dialog))
|
||||
} label: {
|
||||
VStack(spacing: 4) {
|
||||
AvatarView(
|
||||
initials: dialog.initials,
|
||||
colorIndex: dialog.avatarColorIndex,
|
||||
size: 62,
|
||||
isOnline: dialog.isOnline,
|
||||
isSavedMessages: dialog.isSavedMessages
|
||||
)
|
||||
|
||||
Text(dialog.isSavedMessages ? "Saved" : dialog.opponentTitle.components(separatedBy: " ").first ?? "")
|
||||
.font(.system(size: 11))
|
||||
.tracking(0.06)
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
.frame(width: 78)
|
||||
}
|
||||
.frame(width: 78)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,15 +29,19 @@ struct ChatListView: View {
|
||||
@StateObject private var viewModel = ChatListViewModel()
|
||||
@StateObject private var navigationState = ChatListNavigationState()
|
||||
@State private var searchText = ""
|
||||
@FocusState private var isSearchFocused: Bool
|
||||
|
||||
@MainActor static var _bodyCount = 0
|
||||
var body: some View {
|
||||
let _ = Self._bodyCount += 1
|
||||
let _ = print("🟡 ChatListView.body #\(Self._bodyCount)")
|
||||
NavigationStack(path: $navigationState.path) {
|
||||
ZStack {
|
||||
RosettaColors.Adaptive.background
|
||||
.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
// Custom search bar
|
||||
customSearchBar
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
if isSearchActive {
|
||||
ChatListSearchContent(
|
||||
@@ -45,25 +49,26 @@ struct ChatListView: View {
|
||||
viewModel: viewModel,
|
||||
onSelectRecent: { searchText = $0 },
|
||||
onOpenDialog: { route in
|
||||
isSearchActive = false
|
||||
searchText = ""
|
||||
navigationState.path.append(route)
|
||||
// Delay search dismissal so NavigationStack processes
|
||||
// the push before the search overlay is removed.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
isSearchActive = false
|
||||
isSearchFocused = false
|
||||
searchText = ""
|
||||
viewModel.setSearchQuery("")
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
normalContent
|
||||
}
|
||||
}
|
||||
.background(RosettaColors.Adaptive.background.ignoresSafeArea())
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(isSearchActive ? .hidden : .visible, for: .navigationBar)
|
||||
.toolbar { toolbarContent }
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.applyGlassNavBar()
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
isPresented: $isSearchActive,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Search"
|
||||
)
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
.onChange(of: searchText) { _, newValue in
|
||||
viewModel.setSearchQuery(newValue)
|
||||
}
|
||||
@@ -84,6 +89,124 @@ struct ChatListView: View {
|
||||
}
|
||||
.tint(RosettaColors.figmaBlue)
|
||||
}
|
||||
|
||||
// MARK: - Cancel Search
|
||||
|
||||
private func cancelSearch() {
|
||||
isSearchActive = false
|
||||
isSearchFocused = false
|
||||
searchText = ""
|
||||
viewModel.setSearchQuery("")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Custom Search Bar
|
||||
|
||||
private extension ChatListView {
|
||||
var customSearchBar: some View {
|
||||
HStack(spacing: 10) {
|
||||
// Search bar capsule
|
||||
ZStack {
|
||||
// Centered placeholder: magnifier + "Search"
|
||||
if searchText.isEmpty && !isSearchActive {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(Color.gray)
|
||||
Text("Search")
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(Color.gray)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
// Active: left-aligned magnifier + TextField
|
||||
if isSearchActive {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(Color.gray)
|
||||
|
||||
TextField("Search", text: $searchText)
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.focused($isSearchFocused)
|
||||
.submitLabel(.search)
|
||||
|
||||
if !searchText.isEmpty {
|
||||
Button {
|
||||
searchText = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Color.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
.frame(height: 42)
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if !isSearchActive {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
isSearchActive = true
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
isSearchFocused = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.background {
|
||||
if isSearchActive {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
|
||||
}
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.fill(RosettaColors.Adaptive.backgroundSecondary)
|
||||
}
|
||||
}
|
||||
.onChange(of: isSearchFocused) { _, focused in
|
||||
if focused && !isSearchActive {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
isSearchActive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Circular X button (visible only when search is active)
|
||||
if isSearchActive {
|
||||
Button {
|
||||
cancelSearch()
|
||||
} label: {
|
||||
Image("toolbar-xmark")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 19, height: 19)
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
.padding(3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.08))
|
||||
.overlay {
|
||||
Circle()
|
||||
.strokeBorder(Color.white.opacity(0.06), lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.5)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Normal Content
|
||||
@@ -111,40 +234,56 @@ private extension ChatListView {
|
||||
private extension ChatListView {
|
||||
@ToolbarContentBuilder
|
||||
var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button { } label: {
|
||||
Text("Edit")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 4) {
|
||||
// Isolated view — reads AccountManager & SessionManager (@Observable)
|
||||
// without polluting ChatListView's observation scope.
|
||||
ToolbarStoriesAvatar()
|
||||
Text("Chats")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 8) {
|
||||
if !isSearchActive {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button { } label: {
|
||||
Image(systemName: "camera")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
Text("Edit")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.frame(height: 44)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.glassCapsule()
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 4) {
|
||||
// Isolated view — reads AccountManager & SessionManager (@Observable)
|
||||
// without polluting ChatListView's observation scope.
|
||||
ToolbarStoriesAvatar()
|
||||
Text("Chats")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
.accessibilityLabel("Camera")
|
||||
Button { } label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 0) {
|
||||
Button { } label: {
|
||||
Image("toolbar-add-chat")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 22, height: 22)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Add chat")
|
||||
|
||||
Button { } label: {
|
||||
Image("toolbar-compose")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("New chat")
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
.accessibilityLabel("New chat")
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.glassCapsule()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,13 +364,13 @@ private struct ChatListDialogContent: View {
|
||||
}
|
||||
} else {
|
||||
if !viewModel.pinnedDialogs.isEmpty {
|
||||
ForEach(viewModel.pinnedDialogs) { dialog in
|
||||
chatRow(dialog)
|
||||
ForEach(Array(viewModel.pinnedDialogs.enumerated()), id: \.element.id) { index, dialog in
|
||||
chatRow(dialog, isFirst: index == 0)
|
||||
.listRowBackground(RosettaColors.Adaptive.backgroundSecondary)
|
||||
}
|
||||
}
|
||||
ForEach(viewModel.unpinnedDialogs) { dialog in
|
||||
chatRow(dialog)
|
||||
ForEach(Array(viewModel.unpinnedDialogs.enumerated()), id: \.element.id) { index, dialog in
|
||||
chatRow(dialog, isFirst: index == 0 && viewModel.pinnedDialogs.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +384,7 @@ private struct ChatListDialogContent: View {
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
}
|
||||
|
||||
private func chatRow(_ dialog: Dialog) -> some View {
|
||||
private func chatRow(_ dialog: Dialog, isFirst: Bool = false) -> some View {
|
||||
Button {
|
||||
navigationState.path.append(ChatRoute(dialog: dialog))
|
||||
} label: {
|
||||
@@ -253,7 +392,8 @@ private struct ChatListDialogContent: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowSeparator(.visible)
|
||||
.listRowSeparator(isFirst ? .hidden : .visible, edges: .top)
|
||||
.listRowSeparator(.visible, edges: .bottom)
|
||||
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
|
||||
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
@@ -368,4 +508,12 @@ private struct DeviceApprovalBanner: View {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview { ChatListView(isSearchActive: .constant(false), isDetailPresented: .constant(false)) }
|
||||
#Preview("Chat List") {
|
||||
ChatListView(isSearchActive: .constant(false), isDetailPresented: .constant(false))
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
#Preview("Search Active") {
|
||||
ChatListView(isSearchActive: .constant(true), isDetailPresented: .constant(false))
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user