added preview for views
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

This commit is contained in:
wt
2026-09-04 01:45:09 +07:00
parent 1650145d4d
commit 1e2670903f
36 changed files with 744 additions and 16 deletions
+6
View File
@@ -107,6 +107,7 @@
5875F7D2870C985287B36AFA /* MessageReplyFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCAACA105F4B9699F59805F9 /* MessageReplyFallbackTests.swift */; };
58A606B016094F1845CB0119 /* LocationMessagePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */; };
5A5C811D7D19AE7C694273FC /* SASLMechanismPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D79A5AC35479F474FF3F44 /* SASLMechanismPreferenceTests.swift */; };
5C1B4AD1C5FB542B1E7154A7 /* PreviewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 686F6A6EB3740DE94310653B /* PreviewSupport.swift */; };
5D80C9CBCE79DE69A7A6ABA4 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */; };
6000FF9931D3399B53C8506C /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */; };
60CE79BB4D0FB53E126354AA /* GroupConversationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */; };
@@ -123,6 +124,7 @@
670970182A27A26B8E44E687 /* LumaTLSNetworkProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */; };
67143F44FF20A87E8875CFFB /* MediaPreviewProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */; };
67438C8911F9B7362073B591 /* ReplyThreadOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C556A41E640592AF6376B6 /* ReplyThreadOverlay.swift */; };
6826D225FFEA985B6C7AD184 /* PreviewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 686F6A6EB3740DE94310653B /* PreviewSupport.swift */; };
6950D2E35897C3A6C537AE08 /* CallHistorySyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F58259FA8A8DB43464FA0D /* CallHistorySyncTests.swift */; };
6AA0AC6E8E25E8A85D63E7CC /* WatchSessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A5787450609B0C14093679 /* WatchSessionModel.swift */; };
6AB86E72201EB1A61898AD5F /* InlineVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */; };
@@ -385,6 +387,7 @@
63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistorySync.swift; sourceTree = "<group>"; };
6597326A509F73A2993A6B80 /* LayoutCheckUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutCheckUITests.swift; sourceTree = "<group>"; };
6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupConversationTests.swift; sourceTree = "<group>"; };
686F6A6EB3740DE94310653B /* PreviewSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewSupport.swift; sourceTree = "<group>"; };
69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaslFailureMessage.swift; sourceTree = "<group>"; };
6C8698ED5D720AC876F51EE3 /* LumaOMEMO2Module.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaOMEMO2Module.swift; sourceTree = "<group>"; };
6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewerDismissGestureTests.swift; sourceTree = "<group>"; };
@@ -697,6 +700,7 @@
99F26F5B024C5B4BF74CF242 /* MainTabView.swift */,
43A4CE3E678096BA76F2988C /* NewChatView.swift */,
99BE34DC082D19007241AA62 /* NewGroupView.swift */,
686F6A6EB3740DE94310653B /* PreviewSupport.swift */,
515DD6F1F80A848C39EBAE84 /* RootView.swift */,
CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */,
9A338A8EB30206647BDD922F /* SettingsView.swift */,
@@ -1073,6 +1077,7 @@
7809154E961CC8EFE7EE852F /* OMEMODevice.swift in Sources */,
6216C6AA3787BBF776D78E91 /* PhoneWatchBridge.swift in Sources */,
C9CB8C50E85BD8F69ED0105F /* PhotoAttachmentPreview.swift in Sources */,
5C1B4AD1C5FB542B1E7154A7 /* PreviewSupport.swift in Sources */,
067D121E9C8D8EA076D974FB /* RTCVideoRendererView.swift in Sources */,
67438C8911F9B7362073B591 /* ReplyThreadOverlay.swift in Sources */,
ABAFC50EF551861779FFD9A1 /* RootView.swift in Sources */,
@@ -1193,6 +1198,7 @@
097E9E96B82958C505DCA514 /* OMEMODevice.swift in Sources */,
FF07DC53E2877BFDE726132C /* PhoneWatchBridge.swift in Sources */,
46985C2741D425D6486849F3 /* PhotoAttachmentPreview.swift in Sources */,
6826D225FFEA985B6C7AD184 /* PreviewSupport.swift in Sources */,
C7AE34FF0A0A25D0C81D3271 /* RTCVideoRendererView.swift in Sources */,
3CFEAC0C613CC9248DA8E35E /* ReplyThreadOverlay.swift in Sources */,
3E3A82A737D9009D9BDC04E0 /* RootView.swift in Sources */,
+20 -4
View File
@@ -12,6 +12,15 @@ enum RuntimeEnvironment {
return environment["XCTestConfigurationFilePath"] != nil
|| environment["XCTestBundlePath"] != nil
}
/// True inside Xcode preview canvases. The preview host launches the app
/// with `XCODE_RUNNING_FOR_PLAYGROUNDS=1` and shares the real app
/// container, so the model must not load the saved account or start the
/// XMPP/TLS stack there: the JIT executor interposes OpenSSL symbols onto
/// the system BoringSSL and any socket work crashes.
static var isRunningPreviews: Bool {
ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PLAYGROUNDS"] == "1"
}
}
@MainActor
@@ -144,7 +153,10 @@ final class AppModel: ObservableObject {
// stored preferences on launch.
appLockIsEnabled = appLock.isEnabled
appLockBiometricIsEnabled = appLock.biometricUnlockEnabled
isAppLocked = appLock.isEnabled && !RuntimeEnvironment.isRunningTests
isAppLocked =
appLock.isEnabled
&& !RuntimeEnvironment.isRunningTests
&& !RuntimeEnvironment.isRunningPreviews
refreshBiometricAvailability()
xmpp.eventHandler = { [weak self] event in
@@ -222,7 +234,7 @@ final class AppModel: ObservableObject {
}
}
if !RuntimeEnvironment.isRunningTests {
if !RuntimeEnvironment.isRunningTests, !RuntimeEnvironment.isRunningPreviews {
bootstrapTask = Task { [weak self] in
guard let self else { return }
await self.bootstrap()
@@ -320,7 +332,9 @@ final class AppModel: ObservableObject {
#if DEBUG
if applyUITestChatIfRequested() { return }
#endif
guard !RuntimeEnvironment.isRunningTests else { return }
guard !RuntimeEnvironment.isRunningTests,
!RuntimeEnvironment.isRunningPreviews
else { return }
guard let saved = preferences.load() else { return }
prepareStore(for: saved.normalizedJID)
account = saved
@@ -552,7 +566,9 @@ final class AppModel: ObservableObject {
}
func refreshBiometricAvailability() {
guard !RuntimeEnvironment.isRunningTests else { return }
guard !RuntimeEnvironment.isRunningTests,
!RuntimeEnvironment.isRunningPreviews
else { return }
Task.detached(priority: .utility) {
let context = LAContext()
var error: NSError?
+8
View File
@@ -324,3 +324,11 @@ struct AppLockPasscodeSheet: View {
}
}
}
#Preview("Экран блокировки") {
AppLockView(model: PreviewSupport.model)
}
#Preview("Настройка пароля") {
AppLockPasscodeSheet(model: PreviewSupport.model, mode: .setup)
}
@@ -298,3 +298,19 @@ struct AttachmentPreviewView: View {
}
}
}
private struct AttachmentPreviewHost: View {
@State private var drafts = PreviewSupport.drafts()
var body: some View {
AttachmentPreviewView(
model: PreviewSupport.model,
conversation: PreviewSupport.conversation(displayName: "Алиса"),
drafts: $drafts
)
}
}
#Preview {
AttachmentPreviewHost()
}
+15
View File
@@ -241,3 +241,18 @@ private struct CallControlButton: View {
.accessibilityLabel(title)
}
}
#Preview("Входящий видеозвонок") {
CallView(model: PreviewSupport.model, call: PreviewSupport.callSnapshot())
}
#Preview("Аудиозвонок") {
CallView(
model: PreviewSupport.model,
call: PreviewSupport.callSnapshot(
media: [.audio],
phase: .connected,
isSpeakerEnabled: true
)
)
}
+4
View File
@@ -2649,3 +2649,7 @@ private func writePickedImageData(_ data: Data) throws -> URL {
throw error
}
}
#Preview {
PreviewSupport.chatPreview()
}
@@ -326,3 +326,28 @@ enum MediaTimeFormatter {
return String(format: "%d:%02d", seconds / 60, seconds % 60)
}
}
#Preview("Голосовое") {
AudioMessagePlayer(
model: PreviewSupport.model,
message: PreviewSupport.voiceMessage(),
onFallbackTap: {}
)
.padding()
}
#Preview("Аудиофайл") {
AudioMessagePlayer(
model: PreviewSupport.model,
message: PreviewSupport.message(
body: "",
kind: .audio,
localFilename: "track.mp3",
mimeType: "audio/mpeg",
duration: 214,
byteCount: 8_550_000
),
onFallbackTap: {}
)
.padding()
}
@@ -90,3 +90,21 @@ struct AvatarView: View {
)
}
}
#Preview("Контакт") {
AvatarView(
conversation: PreviewSupport.conversation(displayName: "Алиса", isOnline: true)
)
}
#Preview("Группа") {
AvatarView(
conversation: PreviewSupport.conversation(
jid: "team@conference.example.org",
displayName: "Команда Luma",
kind: .group,
isGroupJoined: true,
occupantCount: 7
)
)
}
@@ -50,3 +50,27 @@ struct ReplyThreadBracket: Shape {
return path
}
}
#Preview("Кривая ответа") {
VStack(spacing: 24) {
ReplyThreadCurve(sourceDirection: .incoming)
.stroke(Color.blue, lineWidth: 2)
.frame(width: 90, height: 26)
ReplyThreadCurve(sourceDirection: .outgoing)
.stroke(Color.blue, lineWidth: 2)
.frame(width: 90, height: 26)
}
.padding()
}
#Preview("Скобка ветки") {
HStack(spacing: 40) {
ReplyThreadBracket(opensToTrailing: true)
.stroke(Color.blue, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.frame(width: 38, height: 260)
ReplyThreadBracket(opensToTrailing: false)
.stroke(Color.blue, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.frame(width: 38, height: 260)
}
.padding()
}
@@ -36,3 +36,8 @@ struct ConnectionBanner: View {
}
}
#Preview {
ConnectionBanner(model: PreviewSupport.model, text: "Чаты")
.padding()
}
@@ -58,3 +58,36 @@ struct ConversationRow: View {
.padding(.vertical, 2)
}
}
#Preview("Прямой чат") {
ConversationRow(
conversation: PreviewSupport.conversation(
displayName: "Алиса",
lastMessage: "Привет! Как дела?",
lastActivity: Date().addingTimeInterval(-120),
unreadCount: 2,
isOnline: true
),
imageData: nil,
isEncrypted: true
)
.padding()
}
#Preview("Групповой чат") {
ConversationRow(
conversation: PreviewSupport.conversation(
jid: "team@conference.example.org",
displayName: "Команда Luma",
lastMessage: "Иван: созвон в 15:00",
lastActivity: Date().addingTimeInterval(-3_600),
unreadCount: 5,
kind: .group,
isGroupJoined: true,
occupantCount: 7
),
imageData: nil,
isEncrypted: true
)
.padding()
}
@@ -90,3 +90,7 @@ struct EmojiPickerView: View {
}
}
}
#Preview {
EmojiPickerView { _ in }
}
@@ -71,3 +71,10 @@ struct InlineVideoPlayer: NSViewRepresentable {
}
}
#endif
#Preview {
InlineVideoPlayer(player: AVPlayer())
.frame(width: 260, height: 260)
.background(Color.black)
.clipShape(Circle())
}
@@ -61,3 +61,9 @@ struct LocationMessagePreview: View {
item.openInMaps()
}
}
#Preview {
LocationMessagePreview(
location: GeoLocation(latitude: 55.7558, longitude: 37.6173)!
)
}
+17 -6
View File
@@ -59,11 +59,12 @@ struct MediaViewer: View {
.ignoresSafeArea(edges: .bottom)
VStack(spacing: 0) {
Spacer()
HStack {
Text(item.title)
.font(.headline)
.foregroundStyle(.white)
.lineLimit(1)
// Text(item.title)
// .font(.headline)
// .foregroundStyle(.white)
// .lineLimit(1)
Spacer()
@@ -88,8 +89,6 @@ struct MediaViewer: View {
)
.ignoresSafeArea()
)
Spacer()
}
}
.offset(y: dragOffset)
@@ -398,3 +397,15 @@ private struct ZoomablePhotoView: View {
}
}
}
#Preview {
MediaViewer(
item: MediaViewerItem(
id: "preview",
url: URL(fileURLWithPath: "/tmp/preview-photo.jpg"),
kind: .photo,
title: "IMG_1234.jpg"
),
onClose: {}
)
}
@@ -816,3 +816,74 @@ extension View {
#endif
}
}
#Preview("Входящее") {
MessageBubble(
model: PreviewSupport.model,
message: PreviewSupport.message(),
isSelectionMode: false,
isSelected: false,
onAttachmentTap: {},
onEdit: {},
onReply: {},
onForward: {},
onRetry: {},
onToggleSelection: {},
onBeginSelection: {},
onRetract: {},
onDelete: {},
onReplyTap: { _ in },
onReact: { _ in },
onReactPicker: {}
)
.padding()
}
#Preview("Исходящее") {
MessageBubble(
model: PreviewSupport.model,
message: PreviewSupport.message(
senderJID: "me@example.org",
body: "Отлично! Созвонимся вечером?",
direction: .outgoing,
delivery: .delivered
),
isSelectionMode: false,
isSelected: false,
onAttachmentTap: {},
onEdit: {},
onReply: {},
onForward: {},
onRetry: {},
onToggleSelection: {},
onBeginSelection: {},
onRetract: {},
onDelete: {},
onReplyTap: { _ in },
onReact: { _ in },
onReactPicker: {}
)
.padding()
}
#Preview("Фото") {
MessageBubble(
model: PreviewSupport.model,
message: PreviewSupport.photoMessage(),
isSelectionMode: false,
isSelected: false,
onAttachmentTap: {},
onEdit: {},
onReply: {},
onForward: {},
onRetry: {},
onToggleSelection: {},
onBeginSelection: {},
onRetract: {},
onDelete: {},
onReplyTap: { _ in },
onReact: { _ in },
onReactPicker: {}
)
.padding()
}
@@ -131,3 +131,12 @@ struct PhotoAttachmentPreview: View {
}
}
}
#Preview {
PhotoAttachmentPreview(
model: PreviewSupport.model,
message: PreviewSupport.photoMessage(),
onFallbackTap: {}
)
.padding()
}
@@ -97,3 +97,9 @@ struct RTCVideoRendererView: NSViewRepresentable {
}
}
#endif
#Preview {
RTCVideoRendererView(track: nil)
.frame(width: 280, height: 320)
.background(Color.black)
}
@@ -345,3 +345,27 @@ private struct ReplyThreadMessageRow: View {
return "\(state) \(value)"
}
}
#Preview {
ReplyThreadOverlay(
model: PreviewSupport.model,
rootMessage: PreviewSupport.message(
body: "Кто пойдёт на конференцию?",
timestamp: Date().addingTimeInterval(-3_600)
),
replies: [
PreviewSupport.message(
id: "preview-reply-1",
body: "Я пойду!",
timestamp: Date().addingTimeInterval(-2_400)
),
PreviewSupport.message(
id: "preview-reply-2",
body: "Я тоже, но опоздаю на полчаса",
timestamp: Date().addingTimeInterval(-1_800)
),
],
selectedReplyID: "preview-reply-1",
onDismiss: {}
)
}
@@ -149,3 +149,12 @@ struct VideoAttachmentPreview: View {
}
}
}
#Preview {
VideoAttachmentPreview(
model: PreviewSupport.model,
message: PreviewSupport.videoMessage(),
onFallbackTap: {}
)
.padding()
}
@@ -50,7 +50,7 @@ struct VideoNotePreview: View {
VStack {
Spacer()
HStack {
Spacer()
// Spacer()
Text(durationText)
.font(.caption2.monospacedDigit().weight(.semibold))
.foregroundStyle(.white)
@@ -153,3 +153,12 @@ struct VideoNotePreview: View {
isPlaying.toggle()
}
}
#Preview {
VideoNotePreview(
model: PreviewSupport.model,
message: PreviewSupport.videoNoteMessage(),
onFallbackTap: {}
)
.padding()
}
@@ -163,3 +163,10 @@ extension String {
return result
}
}
#Preview {
EncryptionDevicesView(
model: PreviewSupport.model,
conversation: PreviewSupport.conversation(displayName: "Алиса")
)
}
@@ -152,3 +152,19 @@ struct ForwardMessageView: View {
return "\(count) \(word)"
}
}
#Preview {
ForwardMessageView(
model: PreviewSupport.model,
messages: [
PreviewSupport.message(body: "Привет! Как дела?"),
PreviewSupport.message(
senderJID: "me@example.org",
body: "Созвонимся вечером?",
direction: .outgoing,
delivery: .sent
),
]
)
.modelContainer(PreviewSupport.container)
}
+13
View File
@@ -139,3 +139,16 @@ struct GroupInfoView: View {
}
}
}
#Preview {
GroupInfoView(
model: PreviewSupport.model,
conversation: PreviewSupport.conversation(
jid: "team@conference.example.org",
displayName: "Команда Luma",
kind: .group,
isGroupJoined: true,
occupantCount: 7
)
)
}
@@ -146,3 +146,7 @@ struct LocationPickerView: View {
dismiss()
}
}
#Preview {
LocationPickerView { _ in }
}
+23 -5
View File
@@ -83,8 +83,10 @@ struct LoginView: View {
#endif
.textContentType(.username)
.autocorrectionDisabled()
.textFieldStyle(.roundedBorder)
.textFieldStyle(.plain)
.disableAutocorrection(true)
.padding()
.glassEffect()
}
VStack(alignment: .leading, spacing: 7) {
@@ -95,9 +97,11 @@ struct LoginView: View {
if passwordVisible {
TextField("Пароль", text: $password)
.textContentType(.oneTimeCode)
.textFieldStyle(.plain)
} else {
SecureField("Пароль", text: $password)
.textContentType(.password)
.textFieldStyle(.plain)
}
}
.autocorrectionDisabled()
@@ -115,25 +119,35 @@ struct LoginView: View {
.buttonStyle(.plain)
.accessibilityLabel(passwordVisible ? "Скрыть пароль" : "Показать пароль")
}
.padding()
.glassEffect()
}
DisclosureGroup("Дополнительные настройки", isExpanded: $showAdvanced) {
VStack(spacing: 14) {
TextField("Отображаемое имя", text: $displayName)
.textFieldStyle(.roundedBorder)
.textFieldStyle(.plain)
.padding()
.glassEffect()
TextField("Автоматически (Luma-XXXX)", text: $resource)
.textFieldStyle(.roundedBorder)
.textFieldStyle(.plain)
.padding()
.glassEffect()
Text(
"Оставьте пустым — Luma создаст уникальный ресурс для этого устройства, чтобы macOS и iOS не конфликтовали между собой."
)
.font(.caption)
.foregroundStyle(.secondary)
TextField("Сервер вручную (необязательно)", text: $manualHost)
.textFieldStyle(.roundedBorder)
.textFieldStyle(.plain)
.padding()
.glassEffect()
TextField(
directTLS ? "Порт, обычно 5223" : "Порт, обычно 5222", text: $manualPort
)
.textFieldStyle(.roundedBorder)
.textFieldStyle(.plain)
.padding()
.glassEffect()
Toggle("Direct TLS", isOn: $directTLS)
.toggleStyle(.switch)
Text(
@@ -189,3 +203,7 @@ struct LoginView: View {
await model.signIn(account: account, password: password)
}
}
#Preview {
LoginView(model: PreviewSupport.model)
}
+4
View File
@@ -1033,3 +1033,7 @@ private struct ContactRow: View {
}
}
#endif
#Preview {
PreviewSupport.mainPreview()
}
+4
View File
@@ -77,3 +77,7 @@ struct NewChatView: View {
}
}
}
#Preview {
NewChatView(model: PreviewSupport.model)
}
+4
View File
@@ -120,3 +120,7 @@ struct NewGroupView: View {
.filter { !$0.isEmpty }
}
}
#Preview {
NewGroupView(model: PreviewSupport.model)
}
+300
View File
@@ -0,0 +1,300 @@
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)
}
}
+4
View File
@@ -91,3 +91,7 @@ struct RootView: View {
)
}
}
#Preview {
RootView(model: PreviewSupport.model)
}
+6
View File
@@ -396,3 +396,9 @@ private extension ServerInformation.CapabilityStatus {
}
}
}
#Preview {
NavigationStack {
ServerInfoView(model: PreviewSupport.model)
}
}
+4
View File
@@ -311,3 +311,7 @@ extension String {
return result
}
}
#Preview {
SettingsView(model: PreviewSupport.model, presentedAsTab: true)
}
@@ -177,4 +177,12 @@ private enum SystemCameraError: LocalizedError {
}
}
}
#Preview {
SystemPhotoCameraView(
onDismissRequest: {},
onMedia: { _ in },
onCancel: {}
)
}
#endif
@@ -190,3 +190,7 @@ struct CameraPreview: NSViewRepresentable {
}
}
#endif
#Preview {
VideoNoteCaptureView { _ in }
}
+6
View File
@@ -226,3 +226,9 @@ private struct WatchChatView: View {
return String(format: "%d:%02d", seconds / 60, seconds % 60)
}
}
#Preview {
MainActor.assumeIsolated {
WatchChatListView(model: WatchSessionModel())
}
}