Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import PackageDescription

let package = Package(
name: "ConversationKit",
platforms: [.iOS(.v17), .macCatalyst(.v17)],
platforms: [.iOS(.v17), .macCatalyst(.v17), .macOS(.v14)],
products: [
.library(
name: "ConversationKit",
Expand Down
6 changes: 3 additions & 3 deletions Sources/ConversationKit/Model/ImageAttachment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ import SwiftUI

public struct ImageAttachment: Attachment {
public let id = UUID()
public let image: UIImage
public let image: PlatformImage

public init(image: UIImage) {
public init(image: PlatformImage) {
self.image = image
}

Expand All @@ -37,7 +37,7 @@ public struct ImageAttachment: Attachment {

extension ImageAttachment: View {
public var body: some View {
Image(uiImage: image)
Image(platformImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 100, height: 100)
Expand Down
74 changes: 74 additions & 0 deletions Sources/ConversationKit/Platform/PlatformTypes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// PlatformTypes.swift
// ConversationKit
//
// Cross-platform type aliases following the Chameleon pattern:
// centralize platform differences so view code stays clean.

import SwiftUI

#if canImport(UIKit)
import UIKit
public typealias PlatformImage = UIImage
#elseif canImport(AppKit)
import AppKit
public typealias PlatformImage = NSImage
#endif

// MARK: - Platform Image Construction

extension PlatformImage {
/// Create a platform image from an SF Symbol name.
static func systemSymbol(_ name: String) -> PlatformImage? {
#if canImport(UIKit)
return UIImage(systemName: name)
#elseif canImport(AppKit)
return NSImage(systemSymbolName: name, accessibilityDescription: nil)
#endif
}
}

// MARK: - Platform Image → SwiftUI Image

extension Image {
/// Create a SwiftUI Image from a platform-native image.
init(platformImage: PlatformImage) {
#if canImport(UIKit)
self.init(uiImage: platformImage)
#elseif canImport(AppKit)
self.init(nsImage: platformImage)
#endif
}
}

// MARK: - Platform Colors

extension Color {
/// Secondary system background — `.secondarySystemBackground` on iOS,
/// `.controlBackgroundColor` on macOS.
static var platformSecondaryBackground: Color {
#if canImport(UIKit)
Color(uiColor: .secondarySystemBackground)
#elseif canImport(AppKit)
Color(nsColor: .controlBackgroundColor)
#endif
}

/// System gray 4 — `.systemGray4` on iOS, `.systemGray` on macOS.
static var platformGray4: Color {
#if canImport(UIKit)
Color(uiColor: .systemGray4)
#elseif canImport(AppKit)
Color(nsColor: .systemGray)
#endif
}

/// Separator color — `.separator` on iOS, `.separatorColor` on macOS.
static var platformSeparator: Color {
#if canImport(UIKit)
Color(uiColor: .separator)
#elseif canImport(AppKit)
Color(nsColor: .separatorColor)
#endif
}
}
6 changes: 3 additions & 3 deletions Sources/ConversationKit/Views/AttachmentPreviewCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public struct AttachmentPreviewCard<AttachmentType: Attachment & View>: View {
public struct ConcentricClipShapeModifier: ViewModifier {
public func body(content: Content) -> some View {
#if compiler(>=6.2)
if #available(iOS 26.0, *) {
if #available(iOS 26.0, macOS 26.0, *) {
content
.clipShape(.rect(corners: Edge.Corner.Style.concentric(minimum: 12), isUniform: false))
} else {
Expand All @@ -60,13 +60,13 @@ public struct ConcentricClipShapeModifier: ViewModifier {
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color(.separator), lineWidth: 0.5)
.stroke(Color.platformSeparator, lineWidth: 0.5)
)
}
}

#Preview {
AttachmentPreviewCard(attachment: ImageAttachment(image: UIImage(systemName: "photo")!)) {
AttachmentPreviewCard(attachment: ImageAttachment(image: PlatformImage.systemSymbol("photo")!)) {
print("Delete action tapped")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ struct AttachmentPreviewScrollView<AttachmentType: Attachment & View>: View {
#Preview {
struct PreviewWrapper: View {
@State var attachments = [
ImageAttachment(image: UIImage(systemName: "photo")!),
ImageAttachment(image: UIImage(systemName: "camera")!),
ImageAttachment(image: UIImage(systemName: "mic")!)
ImageAttachment(image: PlatformImage.systemSymbol("photo")!),
ImageAttachment(image: PlatformImage.systemSymbol("camera")!),
ImageAttachment(image: PlatformImage.systemSymbol("mic")!)
]

var body: some View {
Expand Down
10 changes: 7 additions & 3 deletions Sources/ConversationKit/Views/ConversationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,9 @@ extension ConversationView where AttachmentType == EmptyAttachment {
}
}
.navigationTitle("Chat")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}

Expand Down Expand Up @@ -361,9 +363,9 @@ extension ConversationView where AttachmentType == EmptyAttachment {
Markdown(messageContent)
.padding()
.background {
Color(uiColor: message.participant == .other
? .secondarySystemBackground
: .systemGray4)
message.participant == .other
? Color.platformSecondaryBackground
: Color.platformGray4
}
.roundedCorner(10, corners: .allCorners)
if message.participant == .other {
Expand All @@ -381,6 +383,8 @@ extension ConversationView where AttachmentType == EmptyAttachment {
}
}
.navigationTitle("Chat")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
12 changes: 6 additions & 6 deletions Sources/ConversationKit/Views/MessageComposerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public struct MessageComposerView<AttachmentType: Attachment & View>: View {

public var body: some View {
#if compiler(>=6.2)
if #available(iOS 26.0, *) {
if #available(iOS 26.0, macOS 26.0, *) {
GlassEffectContainer {
HStack(alignment: .bottom) {
if !disableAttachments, let attachmentActions {
Expand Down Expand Up @@ -121,7 +121,7 @@ public struct MessageComposerView<AttachmentType: Attachment & View>: View {
.clipShape(Circle())
.overlay(
Circle()
.stroke(Color(.separator), lineWidth: 0.5)
.stroke(Color.platformSeparator, lineWidth: 0.5)
)
.padding(.trailing, 8)
}
Expand Down Expand Up @@ -156,7 +156,7 @@ public struct MessageComposerView<AttachmentType: Attachment & View>: View {
.clipShape(RoundedRectangle(cornerRadius: 22))
.overlay(
RoundedRectangle(cornerRadius: 22)
.stroke(Color(.separator), lineWidth: 0.5)
.stroke(Color.platformSeparator, lineWidth: 0.5)
)
}
.padding(.top, 8)
Expand All @@ -174,9 +174,9 @@ extension MessageComposerView where AttachmentType == EmptyAttachment {
#Preview("With Attachments") {
@Previewable @State var message = "Hello, world!"
@Previewable @State var attachments = [
ImageAttachment(image: UIImage(systemName: "photo")!),
ImageAttachment(image: UIImage(systemName: "camera")!),
ImageAttachment(image: UIImage(systemName: "mic")!)
ImageAttachment(image: PlatformImage.systemSymbol("photo")!),
ImageAttachment(image: PlatformImage.systemSymbol("camera")!),
ImageAttachment(image: PlatformImage.systemSymbol("mic")!)
]

MessageComposerView(message: $message, attachments: $attachments)
Expand Down
59 changes: 46 additions & 13 deletions Sources/ConversationKit/Views/MessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,62 @@
import SwiftUI
import MarkdownUI

/// Cross-platform corner specification replacing UIRectCorner.
struct RectCorner: OptionSet, Sendable {
let rawValue: Int
static let topLeft = RectCorner(rawValue: 1 << 0)
static let topRight = RectCorner(rawValue: 1 << 1)
static let bottomLeft = RectCorner(rawValue: 1 << 2)
static let bottomRight = RectCorner(rawValue: 1 << 3)
static let allCorners: RectCorner = [.topLeft, .topRight, .bottomLeft, .bottomRight]
}

/// Cross-platform rounded corner shape using pure SwiftUI Path.
struct RoundedCorner: Shape {
var radius: CGFloat = .infinity
var corners: UIRectCorner = .allCorners
var corners: RectCorner = .allCorners

func path(in rect: CGRect) -> Path {
let path = UIBezierPath(
roundedRect: rect,
byRoundingCorners: corners,
cornerRadii: CGSize(width: radius, height: radius)
)
return Path(path.cgPath)
let tl = corners.contains(.topLeft) ? radius : 0
let tr = corners.contains(.topRight) ? radius : 0
let bl = corners.contains(.bottomLeft) ? radius : 0
let br = corners.contains(.bottomRight) ? radius : 0

var path = Path()
path.move(to: CGPoint(x: rect.minX + tl, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - tr, y: rect.minY))
path.addArc(
tangent1End: CGPoint(x: rect.maxX, y: rect.minY),
tangent2End: CGPoint(x: rect.maxX, y: rect.minY + tr),
radius: tr)
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - br))
path.addArc(
tangent1End: CGPoint(x: rect.maxX, y: rect.maxY),
tangent2End: CGPoint(x: rect.maxX - br, y: rect.maxY),
radius: br)
path.addLine(to: CGPoint(x: rect.minX + bl, y: rect.maxY))
path.addArc(
tangent1End: CGPoint(x: rect.minX, y: rect.maxY),
tangent2End: CGPoint(x: rect.minX, y: rect.maxY - bl),
radius: bl)
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + tl))
path.addArc(
tangent1End: CGPoint(x: rect.minX, y: rect.minY),
tangent2End: CGPoint(x: rect.minX + tl, y: rect.minY),
radius: tl)
return path
Comment on lines 38 to +66

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The "path(in rect: CGRect)" method in the "RoundedCorner" shape is quite verbose due to the repeated "addLine" and "addArc" calls for each corner. While functional, encapsulating the logic for adding a single rounded corner segment could improve readability and reduce repetition, making the code easier to maintain in the long run.

}
}

extension View {
func roundedCorner(_ radius: CGFloat, corners: UIRectCorner) -> some View {
func roundedCorner(_ radius: CGFloat, corners: RectCorner) -> some View {
clipShape(RoundedCorner(radius: radius, corners: corners))
}
}

public struct MessageView: View {
@Environment(\.presentErrorAction) var presentErrorAction

let message: String?
let imageURL: String?
let fullWidth: Bool = false
Expand Down Expand Up @@ -97,15 +130,15 @@ public struct MessageView: View {
Markdown(message)
}
}
}
}
.padding()
.if(fullWidth) { view in
view.frame(maxWidth: .infinity, alignment: .leading)
}
.background {
Color(uiColor: participant == .other
? .secondarySystemBackground
: .systemGray4)
participant == .other
? Color.platformSecondaryBackground
: Color.platformGray4
}
.roundedCorner(8, corners: participant == .other ? .topLeft : .topRight)
.roundedCorner(20, corners: participant == .other ? [.topRight, .bottomLeft, .bottomRight] : [.topLeft, .bottomLeft, .bottomRight])
Expand Down