Files
mobile-ios/Rosetta/Features/Chats/Search/SearchView.swift

328 lines
12 KiB
Swift

import SwiftUI
// MARK: - SearchView
struct SearchView: View {
@Binding var isDetailPresented: Bool
@StateObject private var viewModel = SearchViewModel()
@State private var searchText = ""
@State private var navigationPath: [ChatRoute] = []
var body: some View {
NavigationStack(path: $navigationPath) {
ZStack(alignment: .bottom) {
RosettaColors.Adaptive.background
.ignoresSafeArea()
ScrollView {
VStack(spacing: 0) {
if searchText.isEmpty {
FavoriteContactsRow(navigationPath: $navigationPath)
RecentSection(
viewModel: viewModel,
navigationPath: $navigationPath
)
} else {
searchResultsContent
}
Spacer().frame(height: 120)
}
}
.scrollDismissesKeyboard(.immediately)
searchBar
}
.onChange(of: searchText) { _, newValue in
viewModel.setSearchQuery(newValue)
}
.navigationDestination(for: ChatRoute.self) { route in
ChatDetailView(
route: route,
onPresentedChange: { isPresented in
isDetailPresented = isPresented
}
)
}
.onAppear {
isDetailPresented = !navigationPath.isEmpty
}
.onChange(of: navigationPath) { _, newPath in
isDetailPresented = !newPath.isEmpty
}
}
}
}
// MARK: - Search Bar (bottom, Figma style)
private extension SearchView {
var searchBar: some View {
HStack(spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.adaptive(
light: Color(hex: 0x8C8C8C),
dark: Color(hex: 0x8E8E93)
))
TextField("Search", text: $searchText)
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.submitLabel(.search)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
if !searchText.isEmpty {
Button {
searchText = ""
viewModel.clearSearch()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 16))
.foregroundStyle(RosettaColors.adaptive(
light: Color(hex: 0x8C8C8C),
dark: Color(hex: 0x8E8E93)
))
}
}
}
.padding(.horizontal, 11)
.frame(height: 42)
.background {
Capsule()
.fill(RosettaColors.adaptive(
light: Color(hex: 0xF7F7F7),
dark: Color(hex: 0x2A2A2A)
))
}
.applyGlassSearchBar()
Button {
// TODO: Compose new chat
} label: {
Image(systemName: "square.and.pencil")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.adaptive(
light: Color(hex: 0x404040),
dark: Color(hex: 0x8E8E93)
))
.frame(width: 42, height: 42)
.background {
Circle()
.fill(RosettaColors.adaptive(
light: Color(hex: 0xF7F7F7),
dark: Color(hex: 0x2A2A2A)
))
}
.applyGlassSearchBar()
}
.accessibilityLabel("New chat")
}
.padding(.horizontal, 28)
.padding(.bottom, 8)
.background {
RosettaColors.Adaptive.background
.opacity(0.95)
.ignoresSafeArea()
}
}
}
// MARK: - Favorite Contacts (isolated reads DialogRepository in own scope)
/// Isolated child view so that `DialogRepository.shared.sortedDialogs` observation
/// does NOT propagate to `SearchView`'s NavigationStack.
private struct FavoriteContactsRow: View {
@Binding var navigationPath: [ChatRoute]
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 {
navigationPath.append(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)
}
}
}
// MARK: - Recent Section (isolated reads SessionManager in own scope)
/// Isolated child view so that `SessionManager.shared.currentPublicKey` observation
/// does NOT propagate to `SearchView`'s NavigationStack.
private struct RecentSection: View {
@ObservedObject var viewModel: SearchViewModel
@Binding var navigationPath: [ChatRoute]
var body: some View {
if viewModel.recentSearches.isEmpty {
emptyState
} else {
VStack(spacing: 0) {
HStack {
Text("RECENT")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Spacer()
Button {
viewModel.clearRecentSearches()
} label: {
Text("Clear")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 6)
ForEach(viewModel.recentSearches, id: \.publicKey) { user in
recentRow(user)
}
}
}
}
private var emptyState: some View {
VStack(spacing: 16) {
LottieView(
animationName: "search",
animationSpeed: 1.0
)
.frame(width: 120, height: 120)
.padding(.top, 100)
Text("Search for users")
.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)
}
.frame(maxWidth: .infinity)
}
private func recentRow(_ user: RecentSearch) -> some View {
let currentPK = SessionManager.shared.currentPublicKey
let isSelf = user.publicKey == currentPK
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.title, publicKey: user.publicKey)
let effectiveVerified = Self.effectiveVerifiedLevel(
verified: user.verified, title: user.title,
username: user.username, publicKey: user.publicKey
)
return Button {
navigationPath.append(ChatRoute(recent: user))
} label: {
HStack(spacing: 12) {
AvatarView(
initials: initials,
colorIndex: colorIdx,
size: 42,
isSavedMessages: isSelf
)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Text(isSelf ? "Saved Messages" : (user.title.isEmpty ? String(user.publicKey.prefix(16)) + "..." : user.title))
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !isSelf && effectiveVerified > 0 {
VerifiedBadge(
verified: effectiveVerified,
size: 14
)
}
}
// Desktop parity: search subtitle shows @username, not online/offline.
if !isSelf {
Text(user.username.isEmpty
? "@\(String(user.publicKey.prefix(10)))..."
: "@\(user.username)"
)
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 5)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
/// Desktop parity: compute effective verified level server value + client heuristic.
private static func effectiveVerifiedLevel(
verified: Int, title: String, username: String, publicKey: String
) -> Int {
if verified > 0 { return verified }
if title.caseInsensitiveCompare("Rosetta") == .orderedSame { return 1 }
if username.caseInsensitiveCompare("rosetta") == .orderedSame { return 1 }
if title.caseInsensitiveCompare("freddy") == .orderedSame { return 1 }
if username.caseInsensitiveCompare("freddy") == .orderedSame { return 1 }
if SystemAccounts.isSystemAccount(publicKey) { return 1 }
return 0
}
}
// MARK: - Search Results Content
private extension SearchView {
var searchResultsContent: some View {
SearchResultsSection(
isSearching: viewModel.isSearching,
searchResults: viewModel.searchResults,
currentPublicKey: SessionManager.shared.currentPublicKey,
onSelectUser: { user in
viewModel.addToRecent(user)
navigationPath.append(ChatRoute(user: user))
}
)
}
}
// MARK: - Preview
#Preview {
SearchView(isDetailPresented: .constant(false))
}