feat: Introduce system accounts and verification badges
- Added SystemAccounts enum to manage system account keys and titles. - Refactored Dialog model to replace isVerified with verified level. - Implemented effective verification logic for UI display in Dialog. - Updated DialogRepository to handle user verification levels. - Enhanced ProtocolManager and SessionManager to log user info with verification. - Modified AuthCoordinator to support back navigation to unlock screen. - Improved UnlockView and WelcomeView with new account creation flow. - Added VerifiedBadge component to visually represent account verification levels. - Updated ChatListView and SearchView to display verification badges for users. - Cleaned up debug print statements across various components.
This commit is contained in:
67
Rosetta/Features/Chats/ChatList/ChatEmptyStateView.swift
Normal file
67
Rosetta/Features/Chats/ChatList/ChatEmptyStateView.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
import Lottie
|
||||
|
||||
// MARK: - ChatEmptyStateView
|
||||
|
||||
struct ChatEmptyStateView: View {
|
||||
let searchText: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if searchText.isEmpty {
|
||||
noConversationsContent
|
||||
} else {
|
||||
noSearchResultsContent
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.offset(y: -40)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Variants
|
||||
|
||||
private extension ChatEmptyStateView {
|
||||
var noConversationsContent: some View {
|
||||
Group {
|
||||
LottieView(animationName: "letter", loopMode: .playOnce, animationSpeed: 1.0)
|
||||
.frame(width: 150, height: 150)
|
||||
|
||||
Spacer().frame(height: 24)
|
||||
|
||||
Text("No conversations yet")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Spacer().frame(height: 8)
|
||||
|
||||
Text("Start a new conversation to get started")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
}
|
||||
|
||||
var noSearchResultsContent: some View {
|
||||
Group {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
|
||||
|
||||
Spacer().frame(height: 16)
|
||||
|
||||
Text("No results for \"\(searchText)\"")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
ChatEmptyStateView(searchText: "")
|
||||
}
|
||||
244
Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift
Normal file
244
Rosetta/Features/Chats/ChatList/ChatListSearchContent.swift
Normal file
@@ -0,0 +1,244 @@
|
||||
import Lottie
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Chat List Search Content
|
||||
|
||||
/// Search overlay for ChatListView — shows recent searches or search results.
|
||||
/// Matches Android's three-state pattern: skeleton → empty → results.
|
||||
struct ChatListSearchContent: View {
|
||||
let searchText: String
|
||||
@ObservedObject var viewModel: ChatListViewModel
|
||||
var onSelectRecent: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
let trimmed = searchText.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty {
|
||||
recentSearchesSection
|
||||
} else {
|
||||
activeSearchContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Active Search (Three States)
|
||||
|
||||
private extension ChatListSearchContent {
|
||||
/// Android-style: skeleton ↔ empty ↔ results — only one visible at a time.
|
||||
@ViewBuilder
|
||||
var activeSearchContent: some View {
|
||||
let localResults = viewModel.filteredDialogs
|
||||
let localKeys = Set(localResults.map(\.opponentKey))
|
||||
let serverOnly = viewModel.serverSearchResults.filter {
|
||||
!localKeys.contains($0.publicKey)
|
||||
}
|
||||
let hasAnyResult = !localResults.isEmpty || !serverOnly.isEmpty
|
||||
|
||||
if viewModel.isServerSearching && !hasAnyResult {
|
||||
SearchSkeletonView()
|
||||
} else if !viewModel.isServerSearching && !hasAnyResult {
|
||||
noResultsState
|
||||
} else {
|
||||
resultsList(localResults: localResults, serverOnly: serverOnly)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lottie animation + "No results found" — matches Android's empty state.
|
||||
var noResultsState: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
LottieView(animationName: "search", loopMode: .playOnce, animationSpeed: 1.0)
|
||||
.frame(width: 120, height: 120)
|
||||
Text("Search for users")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
Text("Enter username or public key")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
/// Scrollable list of local dialogs + server results.
|
||||
func resultsList(localResults: [Dialog], serverOnly: [SearchUser]) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(localResults) { dialog in
|
||||
ChatRowView(dialog: dialog)
|
||||
}
|
||||
|
||||
ForEach(serverOnly, id: \.publicKey) { user in
|
||||
serverUserRow(user)
|
||||
if user.publicKey != serverOnly.last?.publicKey {
|
||||
Divider()
|
||||
.padding(.leading, 76)
|
||||
.foregroundStyle(RosettaColors.Adaptive.divider)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer().frame(height: 80)
|
||||
}
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recent Searches
|
||||
|
||||
private extension ChatListSearchContent {
|
||||
@ViewBuilder
|
||||
var recentSearchesSection: some View {
|
||||
if viewModel.recentSearches.isEmpty {
|
||||
searchPlaceholder
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("RECENT")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
Spacer()
|
||||
Button { viewModel.clearRecentSearches() } label: {
|
||||
Text("Clear")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 6)
|
||||
|
||||
ForEach(viewModel.recentSearches, id: \.publicKey) { recent in
|
||||
recentRow(recent)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
}
|
||||
|
||||
var searchPlaceholder: some View {
|
||||
VStack(spacing: 20) {
|
||||
Spacer()
|
||||
LottieView(animationName: "search", loopMode: .loop, animationSpeed: 1.0)
|
||||
.frame(width: 120, height: 120)
|
||||
Text("Search for users")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
Text("Find people by username or public key")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
func recentRow(_ user: RecentSearch) -> 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 {
|
||||
onSelectRecent(user.username.isEmpty ? user.publicKey : user.username)
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
AvatarView(
|
||||
initials: initials, colorIndex: colorIdx,
|
||||
size: 42, isSavedMessages: isSelf
|
||||
)
|
||||
Button {
|
||||
viewModel.removeRecentSearch(publicKey: user.publicKey)
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 18, height: 18)
|
||||
.background(Circle().fill(RosettaColors.figmaBlue))
|
||||
}
|
||||
.offset(x: 4, y: -4)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
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.lastSeenText.isEmpty {
|
||||
Text(user.lastSeenText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Server User Row
|
||||
|
||||
private extension ChatListSearchContent {
|
||||
func serverUserRow(_ 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 {
|
||||
viewModel.addToRecent(user)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
AvatarView(
|
||||
initials: initials, colorIndex: colorIdx,
|
||||
size: 48, 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(10))
|
||||
: user.title
|
||||
))
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
if !isSelf && (user.verified > 0 || isRosettaOfficial(user)) {
|
||||
VerifiedBadge(
|
||||
verified: user.verified > 0 ? user.verified : 1,
|
||||
size: 16
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(isSelf ? "Notes" : (
|
||||
user.username.isEmpty
|
||||
? "@\(String(user.publicKey.prefix(10)))..."
|
||||
: "@\(user.username)"
|
||||
))
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import SwiftUI
|
||||
// MARK: - ChatListView
|
||||
|
||||
struct ChatListView: View {
|
||||
@State private var viewModel = ChatListViewModel()
|
||||
@Binding var isSearchActive: Bool
|
||||
@StateObject private var viewModel = ChatListViewModel()
|
||||
@State private var searchText = ""
|
||||
@State private var isSearchPresented = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -13,7 +13,15 @@ struct ChatListView: View {
|
||||
RosettaColors.Adaptive.background
|
||||
.ignoresSafeArea()
|
||||
|
||||
chatContent
|
||||
if isSearchActive {
|
||||
ChatListSearchContent(
|
||||
searchText: searchText,
|
||||
viewModel: viewModel,
|
||||
onSelectRecent: { searchText = $0 }
|
||||
)
|
||||
} else {
|
||||
normalContent
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbarContent }
|
||||
@@ -21,7 +29,7 @@ struct ChatListView: View {
|
||||
.applyGlassNavBar()
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
isPresented: $isSearchPresented,
|
||||
isPresented: $isSearchActive,
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Search"
|
||||
)
|
||||
@@ -33,29 +41,19 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// MARK: - Normal Content
|
||||
|
||||
private extension ChatListView {
|
||||
var chatContent: some View {
|
||||
@ViewBuilder
|
||||
var normalContent: some View {
|
||||
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
|
||||
ChatEmptyStateView(searchText: "")
|
||||
} else {
|
||||
dialogList
|
||||
}
|
||||
}
|
||||
|
||||
var dialogList: some View {
|
||||
List {
|
||||
if viewModel.isLoading {
|
||||
ForEach(0..<8, id: \.self) { _ in
|
||||
@@ -64,29 +62,19 @@ private extension ChatListView {
|
||||
.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.pinnedDialogs) { dialog in
|
||||
chatRow(dialog)
|
||||
.listRowBackground(RosettaColors.Adaptive.backgroundSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(viewModel.unpinnedDialogs) { dialog in
|
||||
chatRow(dialog)
|
||||
}
|
||||
|
||||
// Server search results
|
||||
if viewModel.showServerResults {
|
||||
serverSearchSection
|
||||
}
|
||||
}
|
||||
|
||||
Color.clear
|
||||
.frame(height: 80)
|
||||
Color.clear.frame(height: 80)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
@@ -95,17 +83,6 @@ private extension ChatListView {
|
||||
.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)
|
||||
@@ -119,7 +96,6 @@ private extension ChatListView {
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
|
||||
Button {
|
||||
viewModel.toggleMute(dialog)
|
||||
} label: {
|
||||
@@ -137,7 +113,6 @@ private extension ChatListView {
|
||||
Label("Read", systemImage: "envelope.open")
|
||||
}
|
||||
.tint(RosettaColors.figmaBlue)
|
||||
|
||||
Button {
|
||||
viewModel.togglePin(dialog)
|
||||
} label: {
|
||||
@@ -154,9 +129,7 @@ private extension ChatListView {
|
||||
@ToolbarContentBuilder
|
||||
var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
// TODO: Edit mode
|
||||
} label: {
|
||||
Button { } label: {
|
||||
Text("Edit")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
@@ -166,218 +139,41 @@ private extension ChatListView {
|
||||
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)
|
||||
HStack(spacing: 8) {
|
||||
Button { } label: {
|
||||
Image(systemName: "camera")
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
.accessibilityLabel("Camera")
|
||||
Button { } label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
}
|
||||
.padding(.bottom, 2)
|
||||
.accessibilityLabel("New chat")
|
||||
}
|
||||
.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)
|
||||
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: - Preview
|
||||
#Preview { ChatListView(isSearchActive: .constant(false)) }
|
||||
|
||||
#Preview {
|
||||
ChatListView()
|
||||
}
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
// MARK: - ChatListViewModel
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ChatListViewModel {
|
||||
final class ChatListViewModel: ObservableObject {
|
||||
|
||||
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "ChatListVM")
|
||||
|
||||
// MARK: - State
|
||||
|
||||
private(set) var isLoading = false
|
||||
private(set) var searchQuery = ""
|
||||
@Published var isLoading = false
|
||||
@Published var searchQuery = ""
|
||||
@Published var serverSearchResults: [SearchUser] = []
|
||||
@Published var isServerSearching = false
|
||||
@Published var recentSearches: [RecentSearch] = []
|
||||
|
||||
// Server search state
|
||||
private(set) var serverSearchResults: [SearchUser] = []
|
||||
private(set) var isServerSearching = false
|
||||
private var searchTask: Task<Void, Never>?
|
||||
private var lastSearchedText = ""
|
||||
private static let maxRecent = 20
|
||||
|
||||
private var recentKey: String {
|
||||
"rosetta_recent_searches_\(SessionManager.shared.currentPublicKey)"
|
||||
}
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init() {
|
||||
loadRecentSearches()
|
||||
setupSearchCallback()
|
||||
}
|
||||
|
||||
@@ -25,7 +36,6 @@ final class ChatListViewModel {
|
||||
|
||||
var filteredDialogs: [Dialog] {
|
||||
var result = DialogRepository.shared.sortedDialogs
|
||||
|
||||
let query = searchQuery.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
if !query.isEmpty {
|
||||
result = result.filter {
|
||||
@@ -34,17 +44,11 @@ final class ChatListViewModel {
|
||||
|| $0.lastMessage.lowercased().contains(query)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
var pinnedDialogs: [Dialog] {
|
||||
filteredDialogs.filter(\.isPinned)
|
||||
}
|
||||
|
||||
var unpinnedDialogs: [Dialog] {
|
||||
filteredDialogs.filter { !$0.isPinned }
|
||||
}
|
||||
var pinnedDialogs: [Dialog] { filteredDialogs.filter(\.isPinned) }
|
||||
var unpinnedDialogs: [Dialog] { filteredDialogs.filter { !$0.isPinned } }
|
||||
|
||||
var totalUnreadCount: Int {
|
||||
DialogRepository.shared.sortedDialogs
|
||||
@@ -54,12 +58,6 @@ final class ChatListViewModel {
|
||||
|
||||
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) {
|
||||
@@ -85,6 +83,30 @@ final class ChatListViewModel {
|
||||
|
||||
// MARK: - Server Search
|
||||
|
||||
private func setupSearchCallback() {
|
||||
Self.logger.debug("Setting up search callback")
|
||||
ProtocolManager.shared.onSearchResult = { [weak self] packet in
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else {
|
||||
Self.logger.debug("Search callback: self is nil")
|
||||
return
|
||||
}
|
||||
Self.logger.debug("📥 Search results received: \(packet.users.count) users")
|
||||
self.serverSearchResults = packet.users
|
||||
self.isServerSearching = false
|
||||
Self.logger.debug("📥 isServerSearching=\(self.isServerSearching), count=\(self.serverSearchResults.count)")
|
||||
for user in packet.users {
|
||||
DialogRepository.shared.updateUserInfo(
|
||||
publicKey: user.publicKey,
|
||||
title: user.title,
|
||||
username: user.username,
|
||||
verified: user.verified
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerServerSearch() {
|
||||
searchTask?.cancel()
|
||||
searchTask = nil
|
||||
@@ -97,15 +119,11 @@ final class ChatListViewModel {
|
||||
return
|
||||
}
|
||||
|
||||
if trimmed == 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)
|
||||
@@ -113,41 +131,65 @@ final class ChatListViewModel {
|
||||
|
||||
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)'")
|
||||
Self.logger.debug("📤 Sending search packet for '\(currentQuery)' with hash \(hash.prefix(10))...")
|
||||
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
|
||||
// MARK: - Recent Searches
|
||||
|
||||
for user in packet.users {
|
||||
DialogRepository.shared.updateUserInfo(
|
||||
publicKey: user.publicKey,
|
||||
title: user.title,
|
||||
username: user.username
|
||||
)
|
||||
}
|
||||
}
|
||||
func addToRecent(_ user: SearchUser) {
|
||||
let recent = RecentSearch(
|
||||
publicKey: user.publicKey,
|
||||
title: user.title,
|
||||
username: user.username,
|
||||
lastSeenText: user.online == 1 ? "online" : "last seen recently"
|
||||
)
|
||||
recentSearches.removeAll { $0.publicKey == user.publicKey }
|
||||
recentSearches.insert(recent, at: 0)
|
||||
if recentSearches.count > Self.maxRecent {
|
||||
recentSearches = Array(recentSearches.prefix(Self.maxRecent))
|
||||
}
|
||||
saveRecentSearches()
|
||||
}
|
||||
|
||||
func removeRecentSearch(publicKey: String) {
|
||||
recentSearches.removeAll { $0.publicKey == publicKey }
|
||||
saveRecentSearches()
|
||||
}
|
||||
|
||||
func clearRecentSearches() {
|
||||
recentSearches = []
|
||||
saveRecentSearches()
|
||||
}
|
||||
|
||||
private func loadRecentSearches() {
|
||||
if let data = UserDefaults.standard.data(forKey: recentKey),
|
||||
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) {
|
||||
recentSearches = list
|
||||
return
|
||||
}
|
||||
let oldKey = "rosetta_recent_searches"
|
||||
if let data = UserDefaults.standard.data(forKey: oldKey),
|
||||
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) {
|
||||
recentSearches = list
|
||||
saveRecentSearches()
|
||||
UserDefaults.standard.removeObject(forKey: oldKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveRecentSearches() {
|
||||
guard let data = try? JSONEncoder().encode(recentSearches) else { return }
|
||||
UserDefaults.standard.set(data, forKey: recentKey)
|
||||
}
|
||||
}
|
||||
|
||||
50
Rosetta/Features/Chats/ChatList/ChatRowShimmerView.swift
Normal file
50
Rosetta/Features/Chats/ChatList/ChatRowShimmerView.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - ChatRowShimmerView
|
||||
|
||||
/// Placeholder shimmer row displayed during chat list loading.
|
||||
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)
|
||||
.task {
|
||||
withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
|
||||
phase = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,14 @@ 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%
|
||||
/// Chat row matching Figma "Row - Chats" component spec:
|
||||
/// Row: height 78, paddingLeft 10, paddingRight 16, vertical center
|
||||
/// Avatar: 62px circle, 10pt trailing padding
|
||||
/// Title: SF Pro Medium 17pt, tracking -0.43, primary color
|
||||
/// Message: SF Pro Regular 15pt, tracking -0.23, secondary color
|
||||
/// Time: SF Pro Regular 14pt, tracking -0.23, secondary color
|
||||
/// Badges gap: 6pt — verified 12px, muted 12px
|
||||
/// Trailing: pt 8, pb 14 — readStatus + time (gap 2), pin/count at bottom
|
||||
struct ChatRowView: View {
|
||||
let dialog: Dialog
|
||||
|
||||
@@ -38,21 +41,27 @@ private extension ChatRowView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Section
|
||||
// MARK: - Content Section (two-column: title+detail | trailing accessories)
|
||||
|
||||
private extension ChatRowView {
|
||||
var contentSection: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
titleRow
|
||||
Spacer().frame(height: 3)
|
||||
subtitleRow
|
||||
Spacer(minLength: 0)
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
// Left column: title + message
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
titleRow
|
||||
messageRow
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.clipped()
|
||||
|
||||
// Right column: time + pin/badge
|
||||
trailingColumn
|
||||
}
|
||||
.frame(height: 63)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Title Row (name + badges + delivery + time)
|
||||
// MARK: - Title Row (name + badges)
|
||||
|
||||
private extension ChatRowView {
|
||||
var titleRow: some View {
|
||||
@@ -63,10 +72,11 @@ private extension ChatRowView {
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
|
||||
if dialog.isVerified {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(RosettaColors.figmaBlue)
|
||||
if !dialog.isSavedMessages && dialog.effectiveVerified > 0 {
|
||||
VerifiedBadge(
|
||||
verified: dialog.effectiveVerified,
|
||||
size: 12
|
||||
)
|
||||
}
|
||||
|
||||
if dialog.isMuted {
|
||||
@@ -74,47 +84,19 @@ private extension ChatRowView {
|
||||
.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)
|
||||
// MARK: - Message Row
|
||||
|
||||
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 messageRow: some View {
|
||||
Text(messageText)
|
||||
.font(.system(size: 15))
|
||||
.tracking(-0.23)
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
var messageText: String {
|
||||
@@ -123,6 +105,48 @@ private extension ChatRowView {
|
||||
}
|
||||
return dialog.lastMessage
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trailing Column (time + delivery on top, pin/badge on bottom)
|
||||
|
||||
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, 2)
|
||||
|
||||
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, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var deliveryIcon: some View {
|
||||
@@ -160,9 +184,11 @@ private extension ChatRowView {
|
||||
|
||||
return Text(text)
|
||||
.font(.system(size: 15))
|
||||
.tracking(-0.23)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 4)
|
||||
.frame(minWidth: 20, minHeight: 20)
|
||||
.frame(maxWidth: 37)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue)
|
||||
@@ -208,7 +234,7 @@ private extension ChatRowView {
|
||||
lastMessage: "Hey, how are you?",
|
||||
lastMessageTimestamp: Int64(Date().timeIntervalSince1970 * 1000),
|
||||
unreadCount: 3, isOnline: true, lastSeen: 0,
|
||||
isVerified: true, iHaveSent: true,
|
||||
verified: 1, iHaveSent: true,
|
||||
isPinned: false, isMuted: false,
|
||||
lastMessageFromMe: true, lastMessageDelivered: .read
|
||||
)
|
||||
|
||||
86
Rosetta/Features/Chats/ChatList/SearchSkeletonView.swift
Normal file
86
Rosetta/Features/Chats/ChatList/SearchSkeletonView.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - SearchSkeletonView
|
||||
|
||||
/// Telegram-style skeleton loading for search results.
|
||||
/// Matches the Figma chat row layout: 62px avatar, two-line text, trailing time.
|
||||
struct SearchSkeletonView: View {
|
||||
@State private var phase: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(0..<7, id: \.self) { index in
|
||||
skeletonRow(index: index)
|
||||
if index < 6 {
|
||||
Divider()
|
||||
.foregroundStyle(RosettaColors.Adaptive.divider)
|
||||
.padding(.leading, 82)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollDisabled(true)
|
||||
.task {
|
||||
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
|
||||
phase = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func skeletonRow(index: Int) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
// Avatar — 62pt circle matching Figma
|
||||
Circle()
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 62, height: 62)
|
||||
.padding(.leading, 10)
|
||||
.padding(.trailing, 10)
|
||||
|
||||
// Text block — two lines matching Figma row heights
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Title line — name width varies per row
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: titleWidth(for: index), height: 16)
|
||||
|
||||
// Subtitle line — message preview
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: subtitleWidth(for: index), height: 14)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Trailing time placeholder
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(shimmerGradient)
|
||||
.frame(width: 40, height: 12)
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
.frame(height: 78)
|
||||
}
|
||||
|
||||
// Vary widths to look natural (not uniform blocks)
|
||||
private func titleWidth(for index: Int) -> CGFloat {
|
||||
let widths: [CGFloat] = [130, 100, 160, 90, 140, 110, 150]
|
||||
return widths[index % widths.count]
|
||||
}
|
||||
|
||||
private func subtitleWidth(for index: Int) -> CGFloat {
|
||||
let widths: [CGFloat] = [200, 170, 220, 150, 190, 180, 210]
|
||||
return widths[index % widths.count]
|
||||
}
|
||||
|
||||
private var shimmerGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.gray.opacity(0.08),
|
||||
Color.gray.opacity(0.15),
|
||||
Color.gray.opacity(0.08),
|
||||
],
|
||||
startPoint: UnitPoint(x: phase - 0.4, y: 0),
|
||||
endPoint: UnitPoint(x: phase + 0.4, y: 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user