- 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.
341 lines
12 KiB
Swift
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()
|
|
}
|