Files
Luma/Sources/Shared/UI/PreviewSupport.swift
T
wt 1e2670903f
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
added preview for views
2026-09-04 01:45:09 +07:00

301 lines
9.8 KiB
Swift

import Foundation
import SwiftData
import SwiftUI
/// Shared fixtures for SwiftUI previews.
///
/// Building an `AppModel` wires up the XMPP service, the WebRTC call engine
/// and the keychain-backed stores, so previews share one instance instead of
/// rebuilding it per canvas (the model skips account bootstrap inside the
/// preview host). SwiftData screens share one stable in-memory container and
/// seeded context so `@Query`-backed lists render sample data without
/// touching the real archive or re-fetching on every body evaluation.
enum PreviewSupport {
/// Single shared model: no account, no network traffic, connection is
/// "disconnected" — every screen renders in its resting state.
static let model: AppModel = MainActor.assumeIsolated { AppModel() }
// MARK: - SwiftData
/// Shared in-memory container for `@Query`-backed previews. One stable
/// container/context pair keeps SwiftUI list diffing from seeing a
/// brand-new fetch result on every body evaluation.
static let container: ModelContainer = {
do {
return try ModelContainer(
for: Conversation.self, ChatMessage.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
} catch {
fatalError("Failed to create the preview container: \(error)")
}
}()
/// Seeded context: two direct chats, one group and a short Alice thread.
@MainActor
static let previewContext: ModelContext = {
let context = container.mainContext
let alice = conversation(
jid: "alice@example.org",
displayName: "Алиса",
lastMessage: "Договорились 👌",
lastActivity: Date().addingTimeInterval(-120),
unreadCount: 2,
isOnline: true
)
let bob = conversation(
jid: "bob@example.org",
displayName: "Боб",
lastMessage: "Файл: отчёт.pdf",
lastActivity: Date().addingTimeInterval(-3_600)
)
let team = conversation(
jid: "team@conference.example.org",
displayName: "Команда Luma",
lastMessage: "Иван: созвон в 15:00",
lastActivity: Date().addingTimeInterval(-86_400),
unreadCount: 5,
kind: .group,
isGroupJoined: true,
occupantCount: 7
)
context.insert(alice)
context.insert(bob)
context.insert(team)
let aliceJID = alice.jid
context.insert(
message(
conversationID: aliceJID,
senderJID: aliceJID,
body: "Привет! Как дела?",
timestamp: Date().addingTimeInterval(-600),
direction: .incoming,
delivery: .delivered
)
)
context.insert(
message(
conversationID: aliceJID,
senderJID: "me@example.org",
body: "Отлично! Созвонимся вечером?",
timestamp: Date().addingTimeInterval(-420),
direction: .outgoing,
delivery: .sent
)
)
context.insert(
message(
conversationID: aliceJID,
senderJID: aliceJID,
body: "Договорились 👌",
timestamp: Date().addingTimeInterval(-60),
direction: .incoming,
delivery: .delivered
)
)
return context
}()
// MARK: - Models
static func conversation(
jid: String = "alice@example.org",
displayName: String? = nil,
lastMessage: String = "",
lastActivity: Date = .distantPast,
unreadCount: Int = 0,
isOnline: Bool = false,
isPinned: Bool = false,
encryptionPreference: EncryptionPreference = .inheritGlobal,
kind: Conversation.Kind = .direct,
groupNickname: String? = nil,
isGroupJoined: Bool = false,
shouldAutojoin: Bool = false,
occupantCount: Int = 0,
invitedBy: String? = nil
) -> Conversation {
Conversation(
jid: jid,
displayName: displayName,
lastMessage: lastMessage,
lastActivity: lastActivity,
unreadCount: unreadCount,
isOnline: isOnline,
isPinned: isPinned,
encryptionPreference: encryptionPreference,
kind: kind,
groupNickname: groupNickname,
isGroupJoined: isGroupJoined,
shouldAutojoin: shouldAutojoin,
occupantCount: occupantCount,
invitedBy: invitedBy
)
}
static func message(
id: String = UUID().uuidString,
conversationID: String = "alice@example.org",
senderJID: String = "alice@example.org",
body: String = "Привет! Как дела?",
timestamp: Date = Date(),
direction: ChatMessage.Direction = .incoming,
delivery: ChatMessage.Delivery = .delivered,
security: ChatMessage.Security = .omemo,
kind: ChatMessage.Kind = .text,
remoteAttachmentURL: String? = nil,
localFilename: String? = nil,
mimeType: String? = nil,
duration: TimeInterval? = nil,
byteCount: Int? = nil,
senderDisplayName: String? = nil,
isGroupMessage: Bool = false
) -> ChatMessage {
ChatMessage(
id: id,
conversationID: conversationID,
senderJID: senderJID,
body: body,
timestamp: timestamp,
direction: direction,
delivery: delivery,
security: security,
kind: kind,
remoteAttachmentURL: remoteAttachmentURL,
localFilename: localFilename,
mimeType: mimeType,
duration: duration,
byteCount: byteCount,
senderDisplayName: senderDisplayName,
isGroupMessage: isGroupMessage
)
}
static func photoMessage(
body: String = "Фото с прогулки",
filename: String = "IMG_1234.jpg",
byteCount: Int = 2_420_112
) -> ChatMessage {
message(
body: body,
kind: .photo,
localFilename: filename,
mimeType: "image/jpeg",
byteCount: byteCount
)
}
static func videoMessage(
body: String = "Видео с концерта",
filename: String = "IMG_5678.mov",
duration: TimeInterval = 42,
byteCount: Int = 18_330_240
) -> ChatMessage {
message(
body: body,
kind: .video,
localFilename: filename,
mimeType: "video/quicktime",
duration: duration,
byteCount: byteCount
)
}
static func videoNoteMessage(
body: String = "",
duration: TimeInterval = 12
) -> ChatMessage {
message(
body: body,
kind: .videoNote,
mimeType: "video/mp4",
duration: duration,
byteCount: 1_840_000
)
}
static func voiceMessage(
body: String = "",
duration: TimeInterval = 9
) -> ChatMessage {
message(
body: body,
kind: .voice,
mimeType: "audio/m4a",
duration: duration,
byteCount: 180_000
)
}
static func callSnapshot(
peerJID: String = "alice@example.org",
direction: CallDirection = .incoming,
media: Set<CallMedia> = [.video],
phase: CallPhase = .ringing,
isMuted: Bool = false,
isCameraEnabled: Bool = true,
isSpeakerEnabled: Bool = true
) -> CallSnapshot {
CallSnapshot(
id: UUID(),
peerJID: peerJID,
direction: direction,
media: media,
phase: phase,
connectedAt: phase == .connected ? Date().addingTimeInterval(-75) : nil,
isMuted: isMuted,
isCameraEnabled: isCameraEnabled,
isSpeakerEnabled: isSpeakerEnabled,
hasLocalVideo: media.contains(.video),
hasRemoteVideo: media.contains(.video)
)
}
static func drafts() -> [AttachmentDraft] {
[
AttachmentDraft(
url: URL(fileURLWithPath: "/tmp/preview-photo.jpg"),
filename: "IMG_1234.jpg",
mimeType: "image/jpeg",
kind: .photo,
byteCount: 2_420_112
),
AttachmentDraft(
url: URL(fileURLWithPath: "/tmp/preview-video.mov"),
filename: "IMG_5678.mov",
mimeType: "video/quicktime",
kind: .video,
duration: 21,
byteCount: 18_330_240
),
]
}
// MARK: - Composed scenes
/// Populated chat screen: a conversation plus the seeded Alice thread so
/// the `@Query`-driven timeline renders bubbles instead of an empty
/// state.
@MainActor
static func chatPreview() -> some View {
let conversation = conversation(
jid: "alice@example.org",
displayName: "Алиса",
lastMessage: "Договорились 👌",
lastActivity: Date().addingTimeInterval(-60),
isOnline: true
)
return ChatView(model: model, conversation: conversation)
.environment(\.modelContext, previewContext)
}
/// Main screen backed by the seeded in-memory context so the chat list
/// shows conversations on both platforms.
@MainActor
static func mainPreview() -> some View {
MainTabView(model: model)
.environment(\.modelContext, previewContext)
}
}