Тулбар ChatDetail по Figma: capsule back-кнопка, аватар 44×44, padding и размеры

This commit is contained in:
2026-03-10 00:48:26 +05:00
parent 4dd46b1cf6
commit 2cc780201d
16 changed files with 497 additions and 133 deletions

View File

@@ -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)
}
}
}

View File

@@ -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)
}