Files
mobile-ios/Rosetta/Features/Chats/Search/SearchView.swift
senseiGai 99a35302fa feat: Implement chat list and search functionality
- Added ChatListViewModel to manage chat list state and server search.
- Created ChatRowView for displaying individual chat rows.
- Developed SearchView and SearchViewModel for user search functionality.
- Introduced MainTabView for tab-based navigation between chats and settings.
- Implemented OnboardingPager for onboarding experience.
- Created SettingsView and SettingsViewModel for user settings management.
- Added SplashView for initial app launch experience.
2026-02-25 21:27:41 +05:00

341 lines
12 KiB
Swift

import SwiftUI
// MARK: - SearchView
struct SearchView: View {
@State private var viewModel = SearchViewModel()
@State private var searchText = ""
@FocusState private var isSearchFocused: Bool
var body: some View {
ZStack(alignment: .bottom) {
RosettaColors.Adaptive.background
.ignoresSafeArea()
ScrollView {
VStack(spacing: 0) {
if searchText.isEmpty {
recentSection
} else {
searchResultsContent
}
Spacer().frame(height: 120)
}
}
.scrollDismissesKeyboard(.interactively)
searchBar
}
.onChange(of: searchText) { _, newValue in
print("[SearchView] onChange fired: '\(newValue)'")
viewModel.setSearchQuery(newValue)
}
}
}
// 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)
.focused($isSearchFocused)
.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: - Glass Search Bar Modifier
private struct GlassSearchBarModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
.glassEffect(.regular, in: .capsule)
} else {
content
}
}
}
private extension View {
func applyGlassSearchBar() -> some View {
modifier(GlassSearchBarModifier())
}
}
// MARK: - Recent Section
private extension SearchView {
@ViewBuilder
var recentSection: some View {
if viewModel.recentSearches.isEmpty {
emptyState
} else {
VStack(spacing: 0) {
// Section header
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)
// Recent items
ForEach(viewModel.recentSearches, id: \.publicKey) { user in
recentRow(user)
}
}
}
}
var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "magnifyingglass")
.font(.system(size: 52))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
.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)
}
func recentRow(_ user: RecentSearch) -> some View {
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
return Button {
searchText = user.username.isEmpty ? user.publicKey : user.username
} label: {
HStack(spacing: 12) {
ZStack(alignment: .topTrailing) {
AvatarView(
initials: initials,
colorIndex: colorIdx,
size: 42,
isSavedMessages: isSelf
)
// Close button to remove from recent
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)
}
VStack(alignment: .leading, spacing: 1) {
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 !user.lastSeenText.isEmpty {
Text(user.lastSeenText)
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 5)
}
.buttonStyle(.plain)
}
}
// MARK: - Search Results Content
private extension SearchView {
@ViewBuilder
var searchResultsContent: some View {
if viewModel.isSearching {
VStack(spacing: 12) {
Spacer().frame(height: 40)
ProgressView()
.tint(RosettaColors.Adaptive.textSecondary)
Text("Searching...")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
.frame(maxWidth: .infinity)
} else if viewModel.searchResults.isEmpty {
VStack(spacing: 12) {
Spacer().frame(height: 40)
Image(systemName: "person.slash")
.font(.system(size: 40))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
Text("No users found")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Text("Try a different username or public key")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
}
.frame(maxWidth: .infinity)
} else {
VStack(spacing: 0) {
ForEach(viewModel.searchResults, id: \.publicKey) { user in
searchResultRow(user)
}
}
}
}
func searchResultRow(_ user: SearchUser) -> some View {
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
return Button {
viewModel.addToRecent(user)
// TODO: Navigate to ChatDetailView for user.publicKey
} label: {
HStack(spacing: 12) {
AvatarView(
initials: initials,
colorIndex: colorIdx,
size: 42,
isOnline: user.online == 1,
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 user.verified > 0 {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 12))
.foregroundStyle(RosettaColors.figmaBlue)
}
}
if !user.username.isEmpty {
Text("@\(user.username)")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
}
Spacer()
if user.online == 1 {
Circle()
.fill(RosettaColors.online)
.frame(width: 8, height: 8)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 5)
}
.buttonStyle(.plain)
}
}
// MARK: - Preview
#Preview {
SearchView()
}