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.
This commit is contained in:
2026-02-25 21:27:41 +05:00
parent 7fb57fffba
commit 99a35302fa
54 changed files with 5818 additions and 213 deletions

View File

@@ -0,0 +1,383 @@
import SwiftUI
// MARK: - ChatListView
struct ChatListView: View {
@State private var viewModel = ChatListViewModel()
@State private var searchText = ""
@State private var isSearchPresented = false
var body: some View {
NavigationStack {
ZStack {
RosettaColors.Adaptive.background
.ignoresSafeArea()
chatContent
}
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarContent }
.toolbarBackground(.visible, for: .navigationBar)
.applyGlassNavBar()
.searchable(
text: $searchText,
isPresented: $isSearchPresented,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Search"
)
.onChange(of: searchText) { _, newValue in
viewModel.setSearchQuery(newValue)
}
}
.tint(RosettaColors.figmaBlue)
}
}
// MARK: - Glass Nav Bar Modifier
private struct GlassNavBarModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
} else {
content
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
}
}
}
private extension View {
func applyGlassNavBar() -> some View {
modifier(GlassNavBarModifier())
}
}
// MARK: - Chat Content
private extension ChatListView {
var chatContent: some View {
List {
if viewModel.isLoading {
ForEach(0..<8, id: \.self) { _ in
ChatRowShimmerView()
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
} else if viewModel.filteredDialogs.isEmpty && !viewModel.showServerResults {
emptyState
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} else {
// Local dialog results
if !viewModel.pinnedDialogs.isEmpty {
pinnedSection
}
ForEach(viewModel.unpinnedDialogs) { dialog in
chatRow(dialog)
}
// Server search results
if viewModel.showServerResults {
serverSearchSection
}
}
Color.clear
.frame(height: 80)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollDismissesKeyboard(.interactively)
}
}
// MARK: - Pinned Section
private extension ChatListView {
var pinnedSection: some View {
ForEach(viewModel.pinnedDialogs) { dialog in
chatRow(dialog)
.listRowBackground(RosettaColors.Adaptive.backgroundSecondary)
}
}
func chatRow(_ dialog: Dialog) -> some View {
ChatRowView(dialog: dialog)
.listRowInsets(EdgeInsets())
.listRowSeparator(.visible)
.listRowSeparatorTint(RosettaColors.Adaptive.divider)
.alignmentGuide(.listRowSeparatorLeading) { _ in 82 }
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
withAnimation { viewModel.deleteDialog(dialog) }
} label: {
Label("Delete", systemImage: "trash")
}
Button {
viewModel.toggleMute(dialog)
} label: {
Label(
dialog.isMuted ? "Unmute" : "Mute",
systemImage: dialog.isMuted ? "bell" : "bell.slash"
)
}
.tint(.indigo)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
viewModel.markAsRead(dialog)
} label: {
Label("Read", systemImage: "envelope.open")
}
.tint(RosettaColors.figmaBlue)
Button {
viewModel.togglePin(dialog)
} label: {
Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: "pin")
}
.tint(.orange)
}
}
}
// MARK: - Toolbar
private extension ChatListView {
@ToolbarContentBuilder
var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
Button {
// TODO: Edit mode
} label: {
Text("Edit")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
}
}
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
storiesAvatars
Text("Chats")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.figmaBlue)
}
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button {
// TODO: Camera
} label: {
Image(systemName: "camera")
.font(.system(size: 18))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.accessibilityLabel("Camera")
Button {
// TODO: Compose new message
} label: {
Image(systemName: "square.and.pencil")
.font(.system(size: 18))
.foregroundStyle(RosettaColors.Adaptive.text)
}
.accessibilityLabel("New chat")
}
}
@ViewBuilder
private var storiesAvatars: some View {
let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
let initials = RosettaColors.initials(name: SessionManager.shared.displayName, publicKey: pk)
let colorIdx = RosettaColors.avatarColorIndex(for: pk)
ZStack {
AvatarView(initials: initials, colorIndex: colorIdx, size: 28)
}
}
}
// MARK: - Server Search Results
private extension ChatListView {
@ViewBuilder
var serverSearchSection: some View {
if viewModel.isServerSearching {
HStack {
Spacer()
ProgressView()
.tint(RosettaColors.Adaptive.textSecondary)
Text("Searching users...")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Spacer()
}
.padding(.vertical, 16)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} else if !viewModel.serverSearchResults.isEmpty {
Section {
ForEach(viewModel.serverSearchResults, id: \.publicKey) { user in
serverSearchRow(user)
}
} header: {
Text("GLOBAL SEARCH")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
} else if viewModel.filteredDialogs.isEmpty {
emptyState
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
func serverSearchRow(_ 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 {
// TODO: Navigate to ChatDetailView
} label: {
HStack(spacing: 12) {
AvatarView(
initials: initials,
colorIndex: colorIdx,
size: 52,
isOnline: user.online == 1,
isSavedMessages: isSelf
)
VStack(alignment: .leading, spacing: 2) {
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: 14))
.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, 6)
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets())
}
}
// MARK: - Empty State
private extension ChatListView {
var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: searchText.isEmpty ? "bubble.left.and.bubble.right" : "magnifyingglass")
.font(.system(size: 52))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
.padding(.top, 80)
Text(searchText.isEmpty ? "No chats yet" : "No results for \"\(searchText)\"")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
if searchText.isEmpty {
Text("Start a conversation by tapping the search tab")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Shimmer Row
private struct ChatRowShimmerView: View {
@State private var phase: CGFloat = 0
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(shimmerGradient)
.frame(width: 62, height: 62)
VStack(alignment: .leading, spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 140, height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 200, height: 12)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.onAppear {
withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
var shimmerGradient: LinearGradient {
let baseOpacity = colorScheme == .dark ? 0.06 : 0.08
let peakOpacity = colorScheme == .dark ? 0.12 : 0.16
return LinearGradient(
colors: [
Color.gray.opacity(baseOpacity),
Color.gray.opacity(peakOpacity),
Color.gray.opacity(baseOpacity),
],
startPoint: UnitPoint(x: phase - 0.4, y: 0),
endPoint: UnitPoint(x: phase + 0.4, y: 0)
)
}
}
// MARK: - Preview
#Preview {
ChatListView()
}

View File

@@ -0,0 +1,153 @@
import Foundation
// MARK: - ChatListViewModel
@Observable
@MainActor
final class ChatListViewModel {
// MARK: - State
private(set) var isLoading = false
private(set) var searchQuery = ""
// Server search state
private(set) var serverSearchResults: [SearchUser] = []
private(set) var isServerSearching = false
private var searchTask: Task<Void, Never>?
private var lastSearchedText = ""
init() {
setupSearchCallback()
}
// MARK: - Computed (local dialog filtering)
var filteredDialogs: [Dialog] {
var result = DialogRepository.shared.sortedDialogs
let query = searchQuery.trimmingCharacters(in: .whitespaces).lowercased()
if !query.isEmpty {
result = result.filter {
$0.opponentTitle.lowercased().contains(query)
|| $0.opponentUsername.lowercased().contains(query)
|| $0.lastMessage.lowercased().contains(query)
}
}
return result
}
var pinnedDialogs: [Dialog] {
filteredDialogs.filter(\.isPinned)
}
var unpinnedDialogs: [Dialog] {
filteredDialogs.filter { !$0.isPinned }
}
var totalUnreadCount: Int {
DialogRepository.shared.sortedDialogs
.filter { !$0.isMuted }
.reduce(0) { $0 + $1.unreadCount }
}
var hasUnread: Bool { totalUnreadCount > 0 }
/// True when searching and no local results shows server results section
var showServerResults: Bool {
let query = searchQuery.trimmingCharacters(in: .whitespaces)
return !query.isEmpty
}
// MARK: - Actions
func setSearchQuery(_ query: String) {
searchQuery = query
triggerServerSearch()
}
func deleteDialog(_ dialog: Dialog) {
DialogRepository.shared.deleteDialog(opponentKey: dialog.opponentKey)
}
func togglePin(_ dialog: Dialog) {
DialogRepository.shared.togglePin(opponentKey: dialog.opponentKey)
}
func toggleMute(_ dialog: Dialog) {
DialogRepository.shared.toggleMute(opponentKey: dialog.opponentKey)
}
func markAsRead(_ dialog: Dialog) {
DialogRepository.shared.markAsRead(opponentKey: dialog.opponentKey)
}
// MARK: - Server Search
private func triggerServerSearch() {
searchTask?.cancel()
searchTask = nil
let trimmed = searchQuery.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
serverSearchResults = []
isServerSearching = false
lastSearchedText = ""
return
}
if trimmed == lastSearchedText {
return
}
isServerSearching = true
searchTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(1))
guard let self, !Task.isCancelled else { return }
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
guard !currentQuery.isEmpty, currentQuery == trimmed else { return }
let connState = ProtocolManager.shared.connectionState
let hash = SessionManager.shared.privateKeyHash
print("[Search] connState=\(connState.rawValue), hasHash=\(hash != nil), query='\(currentQuery)'")
guard connState == .authenticated, let hash else {
print("[Search] NOT AUTHENTICATED - aborting")
self.isServerSearching = false
return
}
self.lastSearchedText = currentQuery
var packet = PacketSearch()
packet.privateKey = hash
packet.search = currentQuery
print("[Search] Sending PacketSearch for '\(currentQuery)'")
ProtocolManager.shared.sendPacket(packet)
}
}
private func setupSearchCallback() {
print("[Search] Setting up search callback")
ProtocolManager.shared.onSearchResult = { [weak self] packet in
print("[Search] CALLBACK: received \(packet.users.count) users")
Task { @MainActor [weak self] in
guard let self else { return }
self.serverSearchResults = packet.users
self.isServerSearching = false
for user in packet.users {
DialogRepository.shared.updateUserInfo(
publicKey: user.publicKey,
title: user.title,
username: user.username
)
}
}
}
}
}

View File

@@ -0,0 +1,220 @@
import SwiftUI
// MARK: - ChatRowView
/// Chat row matching Figma spec:
/// Row: paddingLeft=10, paddingRight=16, height=78
/// Avatar: 62px + 10pt right padding
/// Title: SFPro-Medium 17pt, message: SFPro-Regular 15pt
/// Time: SFPro-Regular 14pt, subtitle color: #3C3C43/60%
struct ChatRowView: View {
let dialog: Dialog
var body: some View {
HStack(spacing: 0) {
avatarSection
.padding(.trailing, 10)
contentSection
}
.padding(.leading, 10)
.padding(.trailing, 16)
.frame(height: 78)
.contentShape(Rectangle())
}
}
// MARK: - Avatar
private extension ChatRowView {
var avatarSection: some View {
AvatarView(
initials: dialog.initials,
colorIndex: dialog.avatarColorIndex,
size: 62,
isOnline: dialog.isOnline,
isSavedMessages: dialog.isSavedMessages
)
}
}
// MARK: - Content Section
private extension ChatRowView {
var contentSection: some View {
VStack(alignment: .leading, spacing: 0) {
Spacer(minLength: 0)
titleRow
Spacer().frame(height: 3)
subtitleRow
Spacer(minLength: 0)
}
}
}
// MARK: - Title Row (name + badges + delivery + time)
private extension ChatRowView {
var titleRow: some View {
HStack(spacing: 4) {
Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle)
.font(.system(size: 17, weight: .medium))
.tracking(-0.43)
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if dialog.isVerified {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.figmaBlue)
}
if dialog.isMuted {
Image(systemName: "speaker.slash.fill")
.font(.system(size: 12))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
Spacer(minLength: 4)
if dialog.lastMessageFromMe && !dialog.isSavedMessages {
deliveryIcon
}
Text(formattedTime)
.font(.system(size: 14))
.foregroundStyle(
dialog.unreadCount > 0 && !dialog.isMuted
? RosettaColors.figmaBlue
: RosettaColors.Adaptive.textSecondary
)
}
}
}
// MARK: - Subtitle Row (message + pin + badge)
private extension ChatRowView {
var subtitleRow: some View {
HStack(spacing: 4) {
Text(messageText)
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
Spacer(minLength: 4)
if dialog.isPinned && dialog.unreadCount == 0 {
Image(systemName: "pin.fill")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.rotationEffect(.degrees(45))
}
if dialog.unreadCount > 0 {
unreadBadge
}
}
}
var messageText: String {
if dialog.lastMessage.isEmpty {
return "No messages yet"
}
return dialog.lastMessage
}
@ViewBuilder
var deliveryIcon: some View {
switch dialog.lastMessageDelivered {
case .waiting:
Image(systemName: "clock")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
case .delivered:
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
case .read:
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(RosettaColors.figmaBlue)
.overlay(alignment: .leading) {
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(RosettaColors.figmaBlue)
.offset(x: -4)
}
.padding(.trailing, 2)
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.error)
}
}
var unreadBadge: some View {
let count = dialog.unreadCount
let text = count > 999 ? "\(count / 1000)K" : (count > 99 ? "99+" : "\(count)")
let isMuted = dialog.isMuted
return Text(text)
.font(.system(size: 15))
.foregroundStyle(.white)
.padding(.horizontal, 4)
.frame(minWidth: 20, minHeight: 20)
.background {
Capsule()
.fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue)
}
}
}
// MARK: - Time Formatting
private extension ChatRowView {
var formattedTime: String {
guard dialog.lastMessageTimestamp > 0 else { return "" }
let date = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000)
let now = Date()
let calendar = Calendar.current
if calendar.isDateInToday(date) {
let f = DateFormatter()
f.dateFormat = "h:mm a"
return f.string(from: date)
} else if calendar.isDateInYesterday(date) {
return "Yesterday"
} else if let days = calendar.dateComponents([.day], from: date, to: now).day, days < 7 {
let f = DateFormatter()
f.dateFormat = "EEE"
return f.string(from: date)
} else {
let f = DateFormatter()
f.dateFormat = "dd.MM.yy"
return f.string(from: date)
}
}
}
// MARK: - Preview
#Preview {
let sampleDialog = Dialog(
id: "preview", account: "mykey", opponentKey: "abc001",
opponentTitle: "Alice Johnson",
opponentUsername: "alice",
lastMessage: "Hey, how are you?",
lastMessageTimestamp: Int64(Date().timeIntervalSince1970 * 1000),
unreadCount: 3, isOnline: true, lastSeen: 0,
isVerified: true, iHaveSent: true,
isPinned: false, isMuted: false,
lastMessageFromMe: true, lastMessageDelivered: .read
)
VStack(spacing: 0) {
ChatRowView(dialog: sampleDialog)
}
.background(RosettaColors.Adaptive.background)
}