diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index b5259a8..1112b1e 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -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 = ""; }; 6597326A509F73A2993A6B80 /* LayoutCheckUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutCheckUITests.swift; sourceTree = ""; }; 6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupConversationTests.swift; sourceTree = ""; }; + 686F6A6EB3740DE94310653B /* PreviewSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewSupport.swift; sourceTree = ""; }; 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaslFailureMessage.swift; sourceTree = ""; }; 6C8698ED5D720AC876F51EE3 /* LumaOMEMO2Module.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaOMEMO2Module.swift; sourceTree = ""; }; 6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewerDismissGestureTests.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift index e763344..7f1ff02 100644 --- a/Sources/Shared/Models/AppModel.swift +++ b/Sources/Shared/Models/AppModel.swift @@ -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? diff --git a/Sources/Shared/UI/AppLockView.swift b/Sources/Shared/UI/AppLockView.swift index 88eb599..4f1bf1d 100644 --- a/Sources/Shared/UI/AppLockView.swift +++ b/Sources/Shared/UI/AppLockView.swift @@ -324,3 +324,11 @@ struct AppLockPasscodeSheet: View { } } } + +#Preview("Экран блокировки") { + AppLockView(model: PreviewSupport.model) +} + +#Preview("Настройка пароля") { + AppLockPasscodeSheet(model: PreviewSupport.model, mode: .setup) +} diff --git a/Sources/Shared/UI/AttachmentPreviewView.swift b/Sources/Shared/UI/AttachmentPreviewView.swift index 0379b26..f428d0a 100644 --- a/Sources/Shared/UI/AttachmentPreviewView.swift +++ b/Sources/Shared/UI/AttachmentPreviewView.swift @@ -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() +} diff --git a/Sources/Shared/UI/CallView.swift b/Sources/Shared/UI/CallView.swift index 459dc6a..72576bb 100644 --- a/Sources/Shared/UI/CallView.swift +++ b/Sources/Shared/UI/CallView.swift @@ -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 + ) + ) +} diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift index ca3b709..cc1fa47 100644 --- a/Sources/Shared/UI/ChatView.swift +++ b/Sources/Shared/UI/ChatView.swift @@ -2649,3 +2649,7 @@ private func writePickedImageData(_ data: Data) throws -> URL { throw error } } + +#Preview { + PreviewSupport.chatPreview() +} diff --git a/Sources/Shared/UI/Components/AudioMessagePlayer.swift b/Sources/Shared/UI/Components/AudioMessagePlayer.swift index ef8e796..c4cb216 100644 --- a/Sources/Shared/UI/Components/AudioMessagePlayer.swift +++ b/Sources/Shared/UI/Components/AudioMessagePlayer.swift @@ -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() +} diff --git a/Sources/Shared/UI/Components/AvatarView.swift b/Sources/Shared/UI/Components/AvatarView.swift index 7e716a8..c7ebaac 100644 --- a/Sources/Shared/UI/Components/AvatarView.swift +++ b/Sources/Shared/UI/Components/AvatarView.swift @@ -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 + ) + ) +} diff --git a/Sources/Shared/UI/Components/BubbleShapes.swift b/Sources/Shared/UI/Components/BubbleShapes.swift index c2a23c8..9a2bbb3 100644 --- a/Sources/Shared/UI/Components/BubbleShapes.swift +++ b/Sources/Shared/UI/Components/BubbleShapes.swift @@ -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() +} diff --git a/Sources/Shared/UI/Components/ConnectionBanner.swift b/Sources/Shared/UI/Components/ConnectionBanner.swift index 1638ae1..0685527 100644 --- a/Sources/Shared/UI/Components/ConnectionBanner.swift +++ b/Sources/Shared/UI/Components/ConnectionBanner.swift @@ -36,3 +36,8 @@ struct ConnectionBanner: View { } } +#Preview { + ConnectionBanner(model: PreviewSupport.model, text: "Чаты") + .padding() +} + diff --git a/Sources/Shared/UI/Components/ConversationRow.swift b/Sources/Shared/UI/Components/ConversationRow.swift index bc314e4..d6c1d7b 100644 --- a/Sources/Shared/UI/Components/ConversationRow.swift +++ b/Sources/Shared/UI/Components/ConversationRow.swift @@ -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() +} diff --git a/Sources/Shared/UI/Components/EmojiPickerView.swift b/Sources/Shared/UI/Components/EmojiPickerView.swift index 52d5753..bba0f89 100644 --- a/Sources/Shared/UI/Components/EmojiPickerView.swift +++ b/Sources/Shared/UI/Components/EmojiPickerView.swift @@ -90,3 +90,7 @@ struct EmojiPickerView: View { } } } + +#Preview { + EmojiPickerView { _ in } +} diff --git a/Sources/Shared/UI/Components/InlineVideoPlayer.swift b/Sources/Shared/UI/Components/InlineVideoPlayer.swift index 697b92d..2490e76 100644 --- a/Sources/Shared/UI/Components/InlineVideoPlayer.swift +++ b/Sources/Shared/UI/Components/InlineVideoPlayer.swift @@ -71,3 +71,10 @@ struct InlineVideoPlayer: NSViewRepresentable { } } #endif + +#Preview { + InlineVideoPlayer(player: AVPlayer()) + .frame(width: 260, height: 260) + .background(Color.black) + .clipShape(Circle()) +} diff --git a/Sources/Shared/UI/Components/LocationMessagePreview.swift b/Sources/Shared/UI/Components/LocationMessagePreview.swift index d3b1198..c795771 100644 --- a/Sources/Shared/UI/Components/LocationMessagePreview.swift +++ b/Sources/Shared/UI/Components/LocationMessagePreview.swift @@ -61,3 +61,9 @@ struct LocationMessagePreview: View { item.openInMaps() } } + +#Preview { + LocationMessagePreview( + location: GeoLocation(latitude: 55.7558, longitude: 37.6173)! + ) +} diff --git a/Sources/Shared/UI/Components/MediaViewer.swift b/Sources/Shared/UI/Components/MediaViewer.swift index efad35b..1a14fa2 100644 --- a/Sources/Shared/UI/Components/MediaViewer.swift +++ b/Sources/Shared/UI/Components/MediaViewer.swift @@ -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: {} + ) +} diff --git a/Sources/Shared/UI/Components/MessageBubble.swift b/Sources/Shared/UI/Components/MessageBubble.swift index 50ef8d6..7b135e6 100644 --- a/Sources/Shared/UI/Components/MessageBubble.swift +++ b/Sources/Shared/UI/Components/MessageBubble.swift @@ -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() +} diff --git a/Sources/Shared/UI/Components/PhotoAttachmentPreview.swift b/Sources/Shared/UI/Components/PhotoAttachmentPreview.swift index 0e569f1..f8f98e3 100644 --- a/Sources/Shared/UI/Components/PhotoAttachmentPreview.swift +++ b/Sources/Shared/UI/Components/PhotoAttachmentPreview.swift @@ -131,3 +131,12 @@ struct PhotoAttachmentPreview: View { } } } + +#Preview { + PhotoAttachmentPreview( + model: PreviewSupport.model, + message: PreviewSupport.photoMessage(), + onFallbackTap: {} + ) + .padding() +} diff --git a/Sources/Shared/UI/Components/RTCVideoRendererView.swift b/Sources/Shared/UI/Components/RTCVideoRendererView.swift index 246094e..edbf7db 100644 --- a/Sources/Shared/UI/Components/RTCVideoRendererView.swift +++ b/Sources/Shared/UI/Components/RTCVideoRendererView.swift @@ -97,3 +97,9 @@ struct RTCVideoRendererView: NSViewRepresentable { } } #endif + +#Preview { + RTCVideoRendererView(track: nil) + .frame(width: 280, height: 320) + .background(Color.black) +} diff --git a/Sources/Shared/UI/Components/ReplyThreadOverlay.swift b/Sources/Shared/UI/Components/ReplyThreadOverlay.swift index 6dc1813..6f92683 100644 --- a/Sources/Shared/UI/Components/ReplyThreadOverlay.swift +++ b/Sources/Shared/UI/Components/ReplyThreadOverlay.swift @@ -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: {} + ) +} diff --git a/Sources/Shared/UI/Components/VideoAttachmentPreview.swift b/Sources/Shared/UI/Components/VideoAttachmentPreview.swift index c9d2617..f06d13e 100644 --- a/Sources/Shared/UI/Components/VideoAttachmentPreview.swift +++ b/Sources/Shared/UI/Components/VideoAttachmentPreview.swift @@ -149,3 +149,12 @@ struct VideoAttachmentPreview: View { } } } + +#Preview { + VideoAttachmentPreview( + model: PreviewSupport.model, + message: PreviewSupport.videoMessage(), + onFallbackTap: {} + ) + .padding() +} diff --git a/Sources/Shared/UI/Components/VideoNotePreview.swift b/Sources/Shared/UI/Components/VideoNotePreview.swift index 79717e9..3a3a4fd 100644 --- a/Sources/Shared/UI/Components/VideoNotePreview.swift +++ b/Sources/Shared/UI/Components/VideoNotePreview.swift @@ -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() +} diff --git a/Sources/Shared/UI/EncryptionDevicesView.swift b/Sources/Shared/UI/EncryptionDevicesView.swift index 20316b1..211ab9a 100644 --- a/Sources/Shared/UI/EncryptionDevicesView.swift +++ b/Sources/Shared/UI/EncryptionDevicesView.swift @@ -163,3 +163,10 @@ extension String { return result } } + +#Preview { + EncryptionDevicesView( + model: PreviewSupport.model, + conversation: PreviewSupport.conversation(displayName: "Алиса") + ) +} diff --git a/Sources/Shared/UI/ForwardMessageView.swift b/Sources/Shared/UI/ForwardMessageView.swift index 0a4cc8d..7a61c95 100644 --- a/Sources/Shared/UI/ForwardMessageView.swift +++ b/Sources/Shared/UI/ForwardMessageView.swift @@ -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) +} diff --git a/Sources/Shared/UI/GroupInfoView.swift b/Sources/Shared/UI/GroupInfoView.swift index c1d0441..40e20ee 100644 --- a/Sources/Shared/UI/GroupInfoView.swift +++ b/Sources/Shared/UI/GroupInfoView.swift @@ -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 + ) + ) +} diff --git a/Sources/Shared/UI/LocationPickerView.swift b/Sources/Shared/UI/LocationPickerView.swift index 83cc303..18b401e 100644 --- a/Sources/Shared/UI/LocationPickerView.swift +++ b/Sources/Shared/UI/LocationPickerView.swift @@ -146,3 +146,7 @@ struct LocationPickerView: View { dismiss() } } + +#Preview { + LocationPickerView { _ in } +} diff --git a/Sources/Shared/UI/LoginView.swift b/Sources/Shared/UI/LoginView.swift index 5ce3a49..908c4df 100644 --- a/Sources/Shared/UI/LoginView.swift +++ b/Sources/Shared/UI/LoginView.swift @@ -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) +} diff --git a/Sources/Shared/UI/MainTabView.swift b/Sources/Shared/UI/MainTabView.swift index 08535b2..31a4c23 100644 --- a/Sources/Shared/UI/MainTabView.swift +++ b/Sources/Shared/UI/MainTabView.swift @@ -1033,3 +1033,7 @@ private struct ContactRow: View { } } #endif + +#Preview { + PreviewSupport.mainPreview() +} diff --git a/Sources/Shared/UI/NewChatView.swift b/Sources/Shared/UI/NewChatView.swift index 6dd3a18..dd4e804 100644 --- a/Sources/Shared/UI/NewChatView.swift +++ b/Sources/Shared/UI/NewChatView.swift @@ -77,3 +77,7 @@ struct NewChatView: View { } } } + +#Preview { + NewChatView(model: PreviewSupport.model) +} diff --git a/Sources/Shared/UI/NewGroupView.swift b/Sources/Shared/UI/NewGroupView.swift index a128736..f2325e8 100644 --- a/Sources/Shared/UI/NewGroupView.swift +++ b/Sources/Shared/UI/NewGroupView.swift @@ -120,3 +120,7 @@ struct NewGroupView: View { .filter { !$0.isEmpty } } } + +#Preview { + NewGroupView(model: PreviewSupport.model) +} diff --git a/Sources/Shared/UI/PreviewSupport.swift b/Sources/Shared/UI/PreviewSupport.swift new file mode 100644 index 0000000..2f10035 --- /dev/null +++ b/Sources/Shared/UI/PreviewSupport.swift @@ -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 = [.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) + } +} diff --git a/Sources/Shared/UI/RootView.swift b/Sources/Shared/UI/RootView.swift index f5d3b36..e5b96fa 100644 --- a/Sources/Shared/UI/RootView.swift +++ b/Sources/Shared/UI/RootView.swift @@ -91,3 +91,7 @@ struct RootView: View { ) } } + +#Preview { + RootView(model: PreviewSupport.model) +} diff --git a/Sources/Shared/UI/ServerInfoView.swift b/Sources/Shared/UI/ServerInfoView.swift index 3c486f3..5d4e670 100644 --- a/Sources/Shared/UI/ServerInfoView.swift +++ b/Sources/Shared/UI/ServerInfoView.swift @@ -396,3 +396,9 @@ private extension ServerInformation.CapabilityStatus { } } } + +#Preview { + NavigationStack { + ServerInfoView(model: PreviewSupport.model) + } +} diff --git a/Sources/Shared/UI/SettingsView.swift b/Sources/Shared/UI/SettingsView.swift index 0a567c9..0a158c1 100644 --- a/Sources/Shared/UI/SettingsView.swift +++ b/Sources/Shared/UI/SettingsView.swift @@ -311,3 +311,7 @@ extension String { return result } } + +#Preview { + SettingsView(model: PreviewSupport.model, presentedAsTab: true) +} diff --git a/Sources/Shared/UI/SystemPhotoCameraView.swift b/Sources/Shared/UI/SystemPhotoCameraView.swift index 0591a26..a75201a 100644 --- a/Sources/Shared/UI/SystemPhotoCameraView.swift +++ b/Sources/Shared/UI/SystemPhotoCameraView.swift @@ -177,4 +177,12 @@ private enum SystemCameraError: LocalizedError { } } } + +#Preview { + SystemPhotoCameraView( + onDismissRequest: {}, + onMedia: { _ in }, + onCancel: {} + ) +} #endif diff --git a/Sources/Shared/UI/VideoNoteCaptureView.swift b/Sources/Shared/UI/VideoNoteCaptureView.swift index 7a2f047..86eb2e5 100644 --- a/Sources/Shared/UI/VideoNoteCaptureView.swift +++ b/Sources/Shared/UI/VideoNoteCaptureView.swift @@ -190,3 +190,7 @@ struct CameraPreview: NSViewRepresentable { } } #endif + +#Preview { + VideoNoteCaptureView { _ in } +} diff --git a/Sources/Watch/WatchViews.swift b/Sources/Watch/WatchViews.swift index fc91993..0efd364 100644 --- a/Sources/Watch/WatchViews.swift +++ b/Sources/Watch/WatchViews.swift @@ -226,3 +226,9 @@ private struct WatchChatView: View { return String(format: "%d:%02d", seconds / 60, seconds % 60) } } + +#Preview { + MainActor.assumeIsolated { + WatchChatListView(model: WatchSessionModel()) + } +}