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:
220
Rosetta/Features/Chats/ChatList/ChatRowView.swift
Normal file
220
Rosetta/Features/Chats/ChatList/ChatRowView.swift
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user