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:
@@ -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
|
||||
|
||||
69
Rosetta/DesignSystem/Components/AvatarView.swift
Normal file
69
Rosetta/DesignSystem/Components/AvatarView.swift
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
234
Rosetta/DesignSystem/Components/RosettaTabBar.swift
Normal file
234
Rosetta/DesignSystem/Components/RosettaTabBar.swift
Normal 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"),
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user