Files
mobile-ios/Rosetta/Features/Chats/ChatList/ChatRowView.swift

276 lines
9.0 KiB
Swift

import SwiftUI
// MARK: - ChatRowView
/// Chat row matching Figma "Row - Chats" component spec (node 3994:38947):
///
/// Row: height 78, pl-10, pr-16, items-center
/// Avatar: 62px circle, pr-10
/// Contents: flex-col, h-full, items-start, justify-center, pb-px
/// Title and Trailing Accessories: flex-1, gap-6, items-center, w-full
/// Title and Detail: flex-1, h-63, items-start, overflow-clip
/// Title: gap-4, items-center SF Pro Medium 17/22, tracking -0.43
/// Message: h-41 SF Pro Regular 15/20, tracking -0.23, secondary
/// Accessories: h-full, items-center, justify-end
/// Contents-Trailing: flex-col, h-full, items-end, justify-between, pt-8
/// Time: SF Pro Regular 14/20, tracking -0.23, secondary
/// Other: flex-1, items-end, justify-end, pb-14
/// Badge: bg-#008BFF, min-w-20, max-w-37, px-4, rounded-full
/// SF Pro Regular 15/20, black, tracking -0.23
struct ChatRowView: View {
let dialog: Dialog
var displayTitle: String {
if dialog.isSavedMessages { return "Saved Messages" }
if !dialog.opponentTitle.isEmpty { return dialog.opponentTitle }
if !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" }
return String(dialog.opponentKey.prefix(12))
}
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
// Figma "Contents": flex-col, h-full, items-start, justify-center, pb-px
// "Title and Trailing Accessories": flex-1, gap-6, items-center
private extension ChatRowView {
var contentSection: some View {
HStack(alignment: .center, spacing: 6) {
// "Title and Detail": flex-1, h-63, items-start, overflow-clip
VStack(alignment: .leading, spacing: 0) {
titleRow
messageRow
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 63)
.clipped()
// "Accessories and Grabber": h-full, items-center, justify-end
trailingColumn
.frame(maxHeight: .infinity)
}
.frame(maxHeight: .infinity)
.padding(.bottom, 1)
}
}
// MARK: - Title Row (name + badges)
// Figma "Title": gap-4, items-center, w-full
private extension ChatRowView {
var titleRow: some View {
HStack(spacing: 4) {
Text(displayTitle)
.font(.system(size: 17, weight: .medium))
.tracking(-0.43)
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !dialog.isSavedMessages && dialog.effectiveVerified > 0 {
VerifiedBadge(
verified: dialog.effectiveVerified,
size: 12
)
}
if dialog.isMuted {
Image(systemName: "speaker.slash.fill")
.font(.system(size: 12))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
}
}
}
// MARK: - Message Row
// Figma "Message": h-41, SF Pro Regular 15/20, tracking -0.23, secondary
private extension ChatRowView {
var messageRow: some View {
Text(messageText)
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(2)
.frame(height: 41, alignment: .topLeading)
}
var messageText: String {
if dialog.lastMessage.isEmpty {
return "No messages yet"
}
return dialog.lastMessage
}
}
// MARK: - Trailing Column
// Figma "Contents - Trailing": flex-col, h-full, items-end, justify-between, pt-8
// "Read Status and Time": gap-2, items-center
// "Other": flex-1, items-end, justify-end, pb-14
private extension ChatRowView {
var trailingColumn: some View {
VStack(alignment: .trailing, spacing: 0) {
// Top: read status + time
HStack(spacing: 2) {
if dialog.lastMessageFromMe && !dialog.isSavedMessages {
deliveryIcon
}
Text(formattedTime)
.font(.system(size: 14))
.tracking(-0.23)
.foregroundStyle(
dialog.unreadCount > 0 && !dialog.isMuted
? RosettaColors.figmaBlue
: RosettaColors.Adaptive.textSecondary
)
}
.padding(.top, 8)
Spacer(minLength: 0)
// Bottom: pin or unread badge
HStack(spacing: 8) {
if dialog.isPinned && dialog.unreadCount == 0 {
Image(systemName: "pin.fill")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.rotationEffect(.degrees(45))
}
if dialog.unreadCount > 0 {
unreadBadge
}
}
.padding(.bottom, 14)
}
}
@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
let isSmall = count < 10
return Text(text)
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(.white)
.padding(.horizontal, isSmall ? 0 : 4)
.frame(
minWidth: 20,
maxWidth: isSmall ? 20 : 37,
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,
verified: 1, iHaveSent: true,
isPinned: false, isMuted: false,
lastMessageFromMe: true, lastMessageDelivered: .read
)
VStack(spacing: 0) {
ChatRowView(dialog: sampleDialog)
}
.background(RosettaColors.Adaptive.background)
}