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

@@ -1,4 +1,5 @@
import SwiftUI
import UIKit
// MARK: - Rosetta Color Tokens
@@ -33,15 +34,22 @@ enum RosettaColors {
static let subtleBorder = Color.white.opacity(0.15)
static let cardFill = Color.white.opacity(0.06)
// MARK: Chat-Specific (from Figma)
/// Figma subtitle/time/icon color in light mode: #3C3C43 at ~60% opacity
static let chatSubtitle = Color(hex: 0x3C3C43).opacity(0.6)
/// Figma accent blue used for badges, delivery status: #008BFF
static let figmaBlue = Color(hex: 0x008BFF)
// MARK: Light Theme
enum Light {
static let background = Color.white
static let backgroundSecondary = Color(hex: 0xF2F3F5)
static let backgroundSecondary = Color(hex: 0xF2F2F7) // iOS system grouped bg
static let surface = Color(hex: 0xF5F5F5)
static let text = Color.black
static let textSecondary = Color(hex: 0x666666)
static let textTertiary = Color(hex: 0x999999)
static let textSecondary = Color(hex: 0x3C3C43).opacity(0.6) // Figma subtitle gray
static let textTertiary = Color(hex: 0x3C3C43).opacity(0.3) // Figma hint gray
static let border = Color(hex: 0xE0E0E0)
static let divider = Color(hex: 0xEEEEEE)
static let messageBubble = Color(hex: 0xF5F5F5)
@@ -65,6 +73,30 @@ enum RosettaColors {
static let inputBackground = Color(hex: 0x2A2A2A)
}
// MARK: Adaptive Colors (light/dark based on system appearance)
static func adaptive(light: Color, dark: Color) -> Color {
Color(UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(dark)
: UIColor(light)
})
}
enum Adaptive {
static let background = RosettaColors.adaptive(light: RosettaColors.Light.background, dark: RosettaColors.Dark.background)
static let backgroundSecondary = RosettaColors.adaptive(light: RosettaColors.Light.backgroundSecondary, dark: RosettaColors.Dark.backgroundSecondary)
static let surface = RosettaColors.adaptive(light: RosettaColors.Light.surface, dark: RosettaColors.Dark.surface)
static let text = RosettaColors.adaptive(light: RosettaColors.Light.text, dark: RosettaColors.Dark.text)
static let textSecondary = RosettaColors.adaptive(light: RosettaColors.Light.textSecondary, dark: RosettaColors.Dark.textSecondary)
static let textTertiary = RosettaColors.adaptive(light: RosettaColors.Light.textTertiary, dark: RosettaColors.Dark.textTertiary)
static let border = RosettaColors.adaptive(light: RosettaColors.Light.border, dark: RosettaColors.Dark.border)
static let divider = RosettaColors.adaptive(light: RosettaColors.Light.divider, dark: RosettaColors.Dark.divider)
static let messageBubble = RosettaColors.adaptive(light: RosettaColors.Light.messageBubble, dark: RosettaColors.Dark.messageBubble)
static let messageBubbleOwn = RosettaColors.adaptive(light: RosettaColors.Light.messageBubbleOwn, dark: RosettaColors.Dark.messageBubbleOwn)
static let inputBackground = RosettaColors.adaptive(light: RosettaColors.Light.inputBackground, dark: RosettaColors.Dark.inputBackground)
}
// MARK: Seed Word Colors (12 unique, matching Android)
static let seedWordColors: [Color] = [
@@ -82,18 +114,52 @@ enum RosettaColors {
Color(hex: 0xF7DC6F),
]
// MARK: Avatar Palette
// MARK: Avatar Palette (11 colors, matching rosetta-android dark theme)
static let avatarColors: [(background: Color, text: Color)] = [
(Color(hex: 0xFF6B6B), .white),
(Color(hex: 0x4ECDC4), .white),
(Color(hex: 0x45B7D1), .white),
(Color(hex: 0xF7B731), .white),
(Color(hex: 0x5F27CD), .white),
(Color(hex: 0x00D2D3), .white),
(Color(hex: 0xFF9FF3), .white),
(Color(hex: 0x54A0FF), .white),
(Color(hex: 0x2D3548), Color(hex: 0x7DD3FC)), // blue
(Color(hex: 0x2D4248), Color(hex: 0x67E8F9)), // cyan
(Color(hex: 0x39334C), Color(hex: 0xD8B4FE)), // grape
(Color(hex: 0x2D3F32), Color(hex: 0x86EFAC)), // green
(Color(hex: 0x333448), Color(hex: 0xA5B4FC)), // indigo
(Color(hex: 0x383F2D), Color(hex: 0xBEF264)), // lime
(Color(hex: 0x483529), Color(hex: 0xFDBA74)), // orange
(Color(hex: 0x482D3D), Color(hex: 0xF9A8D4)), // pink
(Color(hex: 0x482D2D), Color(hex: 0xFCA5A5)), // red
(Color(hex: 0x2D4340), Color(hex: 0x5EEAD4)), // teal
(Color(hex: 0x3A334C), Color(hex: 0xC4B5FD)), // violet
]
static func avatarColorIndex(for key: String) -> Int {
var hash: Int32 = 0
for char in key.unicodeScalars {
hash = hash &* 31 &+ Int32(truncatingIfNeeded: char.value)
}
let count = Int32(avatarColors.count)
var index = hash % count
if index < 0 { index += count }
return Int(index)
}
static func avatarText(publicKey: String) -> String {
String(publicKey.prefix(2)).uppercased()
}
static func initials(name: String, publicKey: String) -> String {
let words = name.trimmingCharacters(in: .whitespaces)
.split(whereSeparator: { $0.isWhitespace })
.filter { !$0.isEmpty }
switch words.count {
case 0:
return publicKey.isEmpty ? "??" : String(publicKey.prefix(2)).uppercased()
case 1:
return String(words[0].prefix(2)).uppercased()
default:
let first = words[0].first.map(String.init) ?? ""
let second = words[1].first.map(String.init) ?? ""
return (first + second).uppercased()
}
}
}
// MARK: - Color Hex Initializer

View File

@@ -0,0 +1,69 @@
import SwiftUI
// MARK: - AvatarView
struct AvatarView: View {
let initials: String
let colorIndex: Int
let size: CGFloat
var isOnline: Bool = false
var isSavedMessages: Bool = false
private var backgroundColor: Color {
RosettaColors.avatarColors[colorIndex % RosettaColors.avatarColors.count].background
}
private var textColor: Color {
RosettaColors.avatarColors[colorIndex % RosettaColors.avatarColors.count].text
}
private var fontSize: CGFloat { size * 0.38 }
private var badgeSize: CGFloat { size * 0.31 }
var body: some View {
ZStack {
Circle()
.fill(isSavedMessages ? RosettaColors.primaryBlue : backgroundColor)
if isSavedMessages {
Image(systemName: "bookmark.fill")
.font(.system(size: fontSize, weight: .semibold))
.foregroundStyle(.white)
} else {
Text(initials)
.font(.system(size: fontSize, weight: .semibold, design: .rounded))
.foregroundStyle(textColor)
.lineLimit(1)
.minimumScaleFactor(0.5)
}
}
.frame(width: size, height: size)
.overlay(alignment: .bottomTrailing) {
if isOnline {
Circle()
.fill(RosettaColors.online)
.frame(width: badgeSize, height: badgeSize)
.overlay {
Circle()
.stroke(RosettaColors.Adaptive.background, lineWidth: 2)
}
.offset(x: 1, y: 1)
}
}
.accessibilityLabel(isSavedMessages ? "Saved Messages" : initials)
.accessibilityAddTraits(isOnline ? [.isStaticText] : [])
}
}
// MARK: - Preview
#Preview {
HStack(spacing: 16) {
AvatarView(initials: "AJ", colorIndex: 0, size: 56, isOnline: true)
AvatarView(initials: "BS", colorIndex: 2, size: 56, isOnline: false)
AvatarView(initials: "S", colorIndex: 4, size: 56, isSavedMessages: true)
AvatarView(initials: "CD", colorIndex: 6, size: 40, isOnline: true)
}
.padding()
.background(RosettaColors.Adaptive.background)
}

View File

@@ -23,10 +23,16 @@ struct GlassCard<Content: View>: View {
content()
.background {
RoundedRectangle(cornerRadius: cornerRadius)
.fill(Color.white.opacity(fillOpacity))
.fill(RosettaColors.adaptive(
light: Color.black.opacity(fillOpacity),
dark: Color.white.opacity(fillOpacity)
))
.overlay {
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(Color.white.opacity(0.08), lineWidth: 0.5)
.stroke(RosettaColors.adaptive(
light: Color.black.opacity(0.06),
dark: Color.white.opacity(0.08)
), lineWidth: 0.5)
}
}
}

View File

@@ -1,14 +1,57 @@
import SwiftUI
import Lottie
struct LottieView: UIViewRepresentable {
// MARK: - Animation Cache
final class LottieAnimationCache {
static let shared = LottieAnimationCache()
private var cache: [String: LottieAnimation] = [:]
private let lock = NSLock()
private init() {}
func animation(named name: String) -> LottieAnimation? {
lock.lock()
defer { lock.unlock() }
if let cached = cache[name] {
return cached
}
if let animation = LottieAnimation.named(name) {
cache[name] = animation
return animation
}
return nil
}
func preload(_ names: [String]) {
for name in names {
_ = animation(named: name)
}
}
}
// MARK: - LottieView
struct LottieView: UIViewRepresentable, Equatable {
let animationName: String
var loopMode: LottieLoopMode = .playOnce
var animationSpeed: CGFloat = 1.5
var isPlaying: Bool = true
static func == (lhs: LottieView, rhs: LottieView) -> Bool {
lhs.animationName == rhs.animationName &&
lhs.loopMode == rhs.loopMode &&
lhs.animationSpeed == rhs.animationSpeed &&
lhs.isPlaying == rhs.isPlaying
}
func makeUIView(context: Context) -> LottieAnimationView {
let animationView = LottieAnimationView(name: animationName)
let animationView: LottieAnimationView
if let cached = LottieAnimationCache.shared.animation(named: animationName) {
animationView = LottieAnimationView(animation: cached)
} else {
animationView = LottieAnimationView(name: animationName)
}
animationView.contentMode = .scaleAspectFit
animationView.loopMode = loopMode
animationView.animationSpeed = animationSpeed
@@ -21,13 +64,14 @@ struct LottieView: UIViewRepresentable {
}
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
uiView.loopMode = loopMode
uiView.animationSpeed = animationSpeed
if isPlaying && !uiView.isAnimationPlaying {
uiView.play()
} else if !isPlaying {
uiView.stop()
if isPlaying {
if !uiView.isAnimationPlaying {
uiView.play()
}
} else {
if uiView.isAnimationPlaying {
uiView.stop()
}
}
}
}

View File

@@ -0,0 +1,234 @@
import SwiftUI
import UIKit
// MARK: - Tab
enum RosettaTab: CaseIterable {
case chats
case settings
case search
var label: String {
switch self {
case .chats: return "Chats"
case .settings: return "Settings"
case .search: return ""
}
}
var icon: String {
switch self {
case .chats: return "bubble.left.and.bubble.right"
case .settings: return "gearshape"
case .search: return "magnifyingglass"
}
}
var selectedIcon: String {
switch self {
case .chats: return "bubble.left.and.bubble.right.fill"
case .settings: return "gearshape.fill"
case .search: return "magnifyingglass"
}
}
}
// MARK: - Tab Badge
struct TabBadge {
let tab: RosettaTab
let text: String
}
// MARK: - RosettaTabBar
/// Figma spec:
/// Container: padding(25h, 16t, 25b), gap=8
/// Main pill: 282x62, r=296, padding(4h, 3v), glass+shadow
/// Each tab: 99x56, icon 30pt, label 10pt
/// Selected: #EDEDED rect r=100, icon+label #008BFF, label bold
/// Unselected: icon+label #404040
/// Search pill: 62x62, glass+shadow, icon 17pt #404040
struct RosettaTabBar: View {
let selectedTab: RosettaTab
var onTabSelected: ((RosettaTab) -> Void)?
var badges: [TabBadge] = []
var body: some View {
HStack(spacing: 8) {
mainTabsPill
searchPill
}
.padding(.horizontal, 25)
.padding(.top, 16)
.padding(.bottom, safeAreaBottom > 0 ? safeAreaBottom : 25)
}
}
// MARK: - Main Tabs Pill
private extension RosettaTabBar {
var mainTabsPill: some View {
HStack(spacing: 0) {
ForEach(RosettaTab.allCases.filter { $0 != .search }, id: \.self) { tab in
tabItem(tab)
}
}
.padding(.horizontal, 4)
.padding(.top, 3)
.padding(.bottom, 3)
.frame(height: 62)
.applyGlassPill()
}
func tabItem(_ tab: RosettaTab) -> some View {
let isSelected = tab == selectedTab
let badgeText = badges.first(where: { $0.tab == tab })?.text
return Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
onTabSelected?(tab)
}
} label: {
VStack(spacing: 1) {
ZStack(alignment: .topTrailing) {
Image(systemName: isSelected ? tab.selectedIcon : tab.icon)
.font(.system(size: 22))
.foregroundStyle(isSelected ? RosettaColors.primaryBlue : RosettaColors.adaptive(light: Color(hex: 0x404040), dark: Color(hex: 0x8E8E93)))
.frame(height: 30)
if let badgeText {
badgeView(badgeText)
}
}
Text(tab.label)
.font(.system(size: 10, weight: isSelected ? .bold : .medium))
.foregroundStyle(isSelected ? RosettaColors.primaryBlue : RosettaColors.adaptive(light: Color(hex: 0x404040), dark: Color(hex: 0x8E8E93)))
}
.padding(.horizontal, 8)
.padding(.top, 6)
.padding(.bottom, 7)
.frame(maxWidth: .infinity)
.background {
if isSelected {
RoundedRectangle(cornerRadius: 100)
.fill(RosettaColors.adaptive(
light: Color(hex: 0xEDEDED),
dark: Color.white.opacity(0.12)
))
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(tab.label)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
// MARK: - Search Pill
private extension RosettaTabBar {
var searchPill: some View {
let isSelected = selectedTab == .search
return Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
onTabSelected?(.search)
}
} label: {
Image(systemName: isSelected ? "magnifyingglass" : "magnifyingglass")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(isSelected ? RosettaColors.primaryBlue : RosettaColors.adaptive(light: Color(hex: 0x404040), dark: Color(hex: 0x8E8E93)))
.frame(width: 54, height: 54)
.background {
if isSelected {
Circle()
.fill(RosettaColors.adaptive(
light: Color(hex: 0xEDEDED),
dark: Color.white.opacity(0.12)
))
}
}
}
.buttonStyle(.plain)
.padding(4)
.frame(width: 62, height: 62)
.applyGlassPill()
.accessibilityLabel("Search")
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
// MARK: - Glass Pill
private struct GlassPillModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
.glassEffect(.regular, in: .capsule)
} else {
content
.background(
Capsule()
.fill(RosettaColors.adaptive(
light: Color.white.opacity(0.65),
dark: Color(hex: 0x2A2A2A).opacity(0.8)
))
.shadow(color: RosettaColors.adaptive(
light: Color(hex: 0xDDDDDD).opacity(0.5),
dark: Color.black.opacity(0.3)
), radius: 16, y: 4)
)
}
}
}
private extension View {
func applyGlassPill() -> some View {
modifier(GlassPillModifier())
}
}
// MARK: - Helpers
private extension RosettaTabBar {
func badgeView(_ text: String) -> some View {
Text(text)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, text.count > 2 ? 4 : 0)
.frame(minWidth: 18, minHeight: 18)
.background(Capsule().fill(RosettaColors.error))
.offset(x: 10, y: -4)
}
var safeAreaBottom: CGFloat {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.windows.first(where: \.isKeyWindow) else { return 0 }
return window.safeAreaInsets.bottom
}
}
// MARK: - Preview
#Preview {
ZStack(alignment: .bottom) {
RosettaColors.Adaptive.background.ignoresSafeArea()
VStack {
Spacer()
Text("Content here")
.foregroundStyle(RosettaColors.Adaptive.text)
Spacer()
}
RosettaTabBar(
selectedTab: .chats,
badges: [
TabBadge(tab: .chats, text: "7"),
]
)
}
}