|
|
@@ -0,0 +1,624 @@
|
|
|
+//
|
|
|
+// CVProfileDocumentView.swift
|
|
|
+// App for Indeed
|
|
|
+//
|
|
|
+// Renders saved profile data in a layout that follows the selected CV template’s
|
|
|
+// family (professional / modern / minimal / executive / creative), headline,
|
|
|
+// accent, section labels, and column structure.
|
|
|
+//
|
|
|
+
|
|
|
+import Cocoa
|
|
|
+
|
|
|
+/// Typography and chrome derived from `CVTemplate.family` so the filled résumé
|
|
|
+/// visibly matches the gallery card the user picked—not a single generic layout.
|
|
|
+private struct DocumentStyle {
|
|
|
+ let nameFont: NSFont
|
|
|
+ let roleFont: NSFont
|
|
|
+ let contactFont: NSFont
|
|
|
+ let sectionFont: NSFont
|
|
|
+ let bodyFont: NSFont
|
|
|
+ let bodyCompactFont: NSFont
|
|
|
+ let expTitleFont: NSFont
|
|
|
+ let expMetaFont: NSFont
|
|
|
+ let eduTitleFont: NSFont
|
|
|
+ let eduMetaFont: NSFont
|
|
|
+ let bulletBodyFont: NSFont
|
|
|
+ let bulletMarkerFont: NSFont
|
|
|
+ let bulletMarkerColor: NSColor
|
|
|
+ let ink: NSColor
|
|
|
+ let muted: NSColor
|
|
|
+ let rule: NSColor
|
|
|
+ let cardBackground: NSColor
|
|
|
+ let columnVerticalSpacing: CGFloat
|
|
|
+ let bodyBlockSpacing: CGFloat
|
|
|
+ /// When true, the headline job title uses the template theme color.
|
|
|
+ let roleUsesThemeColor: Bool
|
|
|
+ /// Section heading text color (often theme; executive stays conservative).
|
|
|
+ let sectionInk: NSColor
|
|
|
+
|
|
|
+ static func make(for template: CVTemplate) -> DocumentStyle {
|
|
|
+ let theme = template.themeColor
|
|
|
+ switch template.family {
|
|
|
+ case .minimal:
|
|
|
+ return DocumentStyle(
|
|
|
+ nameFont: .systemFont(ofSize: 20, weight: .regular),
|
|
|
+ roleFont: .systemFont(ofSize: 13.5, weight: .regular),
|
|
|
+ contactFont: .systemFont(ofSize: 11.5, weight: .regular),
|
|
|
+ sectionFont: .systemFont(ofSize: 10.5, weight: .semibold),
|
|
|
+ bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
|
|
|
+ bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
|
|
|
+ expTitleFont: .systemFont(ofSize: 13, weight: .medium),
|
|
|
+ expMetaFont: .systemFont(ofSize: 11.5, weight: .regular),
|
|
|
+ eduTitleFont: .systemFont(ofSize: 13, weight: .medium),
|
|
|
+ eduMetaFont: .systemFont(ofSize: 11.5, weight: .regular),
|
|
|
+ bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
|
|
|
+ bulletMarkerFont: .systemFont(ofSize: 11, weight: .light),
|
|
|
+ bulletMarkerColor: theme.withAlphaComponent(0.55),
|
|
|
+ ink: NSColor(srgbRed: 42 / 255, green: 48 / 255, blue: 56 / 255, alpha: 1),
|
|
|
+ muted: NSColor(srgbRed: 110 / 255, green: 118 / 255, blue: 128 / 255, alpha: 1),
|
|
|
+ rule: NSColor(srgbRed: 228 / 255, green: 230 / 255, blue: 234 / 255, alpha: 1),
|
|
|
+ cardBackground: NSColor(srgbRed: 0.998, green: 0.998, blue: 0.998, alpha: 1),
|
|
|
+ columnVerticalSpacing: 15,
|
|
|
+ bodyBlockSpacing: 15,
|
|
|
+ roleUsesThemeColor: false,
|
|
|
+ sectionInk: theme.withAlphaComponent(0.92)
|
|
|
+ )
|
|
|
+
|
|
|
+ case .professional:
|
|
|
+ return DocumentStyle(
|
|
|
+ nameFont: .systemFont(ofSize: 21, weight: .semibold),
|
|
|
+ roleFont: .systemFont(ofSize: 13.5, weight: .medium),
|
|
|
+ contactFont: .systemFont(ofSize: 11.5, weight: .regular),
|
|
|
+ sectionFont: .systemFont(ofSize: 10.5, weight: .heavy),
|
|
|
+ bodyFont: .systemFont(ofSize: 12, weight: .regular),
|
|
|
+ bodyCompactFont: .systemFont(ofSize: 11.5, weight: .regular),
|
|
|
+ expTitleFont: .systemFont(ofSize: 13.5, weight: .semibold),
|
|
|
+ expMetaFont: .systemFont(ofSize: 11.5, weight: .semibold),
|
|
|
+ eduTitleFont: .systemFont(ofSize: 13, weight: .semibold),
|
|
|
+ eduMetaFont: .systemFont(ofSize: 11.5, weight: .medium),
|
|
|
+ bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
|
|
|
+ bulletMarkerFont: .systemFont(ofSize: 12, weight: .bold),
|
|
|
+ bulletMarkerColor: NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.55),
|
|
|
+ ink: NSColor(srgbRed: 28 / 255, green: 36 / 255, blue: 48 / 255, alpha: 1),
|
|
|
+ muted: NSColor(srgbRed: 88 / 255, green: 98 / 255, blue: 118 / 255, alpha: 1),
|
|
|
+ rule: NSColor(srgbRed: 210 / 255, green: 218 / 255, blue: 232 / 255, alpha: 1),
|
|
|
+ cardBackground: NSColor.white,
|
|
|
+ columnVerticalSpacing: 13,
|
|
|
+ bodyBlockSpacing: 13,
|
|
|
+ roleUsesThemeColor: false,
|
|
|
+ sectionInk: theme
|
|
|
+ )
|
|
|
+
|
|
|
+ case .modern:
|
|
|
+ return DocumentStyle(
|
|
|
+ nameFont: .systemFont(ofSize: 22, weight: .bold),
|
|
|
+ roleFont: .systemFont(ofSize: 14, weight: .semibold),
|
|
|
+ contactFont: .systemFont(ofSize: 12, weight: .regular),
|
|
|
+ sectionFont: .systemFont(ofSize: 11, weight: .heavy),
|
|
|
+ bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
|
|
|
+ bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
|
|
|
+ expTitleFont: .systemFont(ofSize: 14, weight: .bold),
|
|
|
+ expMetaFont: .systemFont(ofSize: 12, weight: .medium),
|
|
|
+ eduTitleFont: .systemFont(ofSize: 13.5, weight: .bold),
|
|
|
+ eduMetaFont: .systemFont(ofSize: 12, weight: .regular),
|
|
|
+ bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
|
|
|
+ bulletMarkerFont: .systemFont(ofSize: 13, weight: .bold),
|
|
|
+ bulletMarkerColor: theme,
|
|
|
+ ink: NSColor(srgbRed: 24 / 255, green: 34 / 255, blue: 52 / 255, alpha: 1),
|
|
|
+ muted: NSColor(srgbRed: 96 / 255, green: 110 / 255, blue: 132 / 255, alpha: 1),
|
|
|
+ rule: NSColor(srgbRed: 200 / 255, green: 214 / 255, blue: 236 / 255, alpha: 1),
|
|
|
+ cardBackground: NSColor(srgbRed: 0.99, green: 0.995, blue: 1, alpha: 1),
|
|
|
+ columnVerticalSpacing: 17,
|
|
|
+ bodyBlockSpacing: 16,
|
|
|
+ roleUsesThemeColor: true,
|
|
|
+ sectionInk: theme
|
|
|
+ )
|
|
|
+
|
|
|
+ case .executive:
|
|
|
+ let serifName = NSFont(name: "Georgia-Bold", size: 23) ?? .systemFont(ofSize: 23, weight: .semibold)
|
|
|
+ let serifRole = NSFont(name: "Georgia", size: 14) ?? .systemFont(ofSize: 14, weight: .regular)
|
|
|
+ let serifBody = NSFont(name: "Georgia", size: 12.5) ?? .systemFont(ofSize: 12.5, weight: .regular)
|
|
|
+ let serifCompact = NSFont(name: "Georgia", size: 12) ?? .systemFont(ofSize: 12, weight: .regular)
|
|
|
+ let georgia12 = NSFont(name: "Georgia", size: 12) ?? .systemFont(ofSize: 12)
|
|
|
+ let georgia115 = NSFont(name: "Georgia", size: 11.5) ?? .systemFont(ofSize: 11.5)
|
|
|
+ let expMeta = NSFont(name: "Georgia-Italic", size: 12)
|
|
|
+ ?? NSFontManager.shared.convert(georgia12, toHaveTrait: .italicFontMask)
|
|
|
+ let eduMeta = NSFont(name: "Georgia-Italic", size: 11.5)
|
|
|
+ ?? NSFontManager.shared.convert(georgia115, toHaveTrait: .italicFontMask)
|
|
|
+ return DocumentStyle(
|
|
|
+ nameFont: serifName,
|
|
|
+ roleFont: serifRole,
|
|
|
+ contactFont: NSFont(name: "Georgia", size: 11.5) ?? .systemFont(ofSize: 11.5),
|
|
|
+ sectionFont: .systemFont(ofSize: 10.5, weight: .heavy),
|
|
|
+ bodyFont: serifBody,
|
|
|
+ bodyCompactFont: serifCompact,
|
|
|
+ expTitleFont: NSFont(name: "Georgia-Bold", size: 14) ?? .systemFont(ofSize: 14, weight: .semibold),
|
|
|
+ expMetaFont: expMeta,
|
|
|
+ eduTitleFont: NSFont(name: "Georgia-Bold", size: 13.5) ?? .systemFont(ofSize: 13.5, weight: .semibold),
|
|
|
+ eduMetaFont: eduMeta,
|
|
|
+ bulletBodyFont: serifCompact,
|
|
|
+ bulletMarkerFont: .systemFont(ofSize: 11, weight: .bold),
|
|
|
+ bulletMarkerColor: NSColor(srgbRed: 55 / 255, green: 55 / 255, blue: 62 / 255, alpha: 1),
|
|
|
+ ink: NSColor(srgbRed: 22 / 255, green: 22 / 255, blue: 28 / 255, alpha: 1),
|
|
|
+ muted: NSColor(srgbRed: 82 / 255, green: 82 / 255, blue: 90 / 255, alpha: 1),
|
|
|
+ rule: NSColor(srgbRed: 72 / 255, green: 72 / 255, blue: 78 / 255, alpha: 0.35),
|
|
|
+ cardBackground: NSColor(srgbRed: 0.992, green: 0.99, blue: 0.985, alpha: 1),
|
|
|
+ columnVerticalSpacing: 18,
|
|
|
+ bodyBlockSpacing: 17,
|
|
|
+ roleUsesThemeColor: false,
|
|
|
+ sectionInk: NSColor(srgbRed: 32 / 255, green: 32 / 255, blue: 38 / 255, alpha: 1)
|
|
|
+ )
|
|
|
+
|
|
|
+ case .creative:
|
|
|
+ return DocumentStyle(
|
|
|
+ nameFont: .systemFont(ofSize: 23, weight: .heavy),
|
|
|
+ roleFont: .systemFont(ofSize: 14, weight: .semibold),
|
|
|
+ contactFont: .systemFont(ofSize: 11.5, weight: .medium),
|
|
|
+ sectionFont: .systemFont(ofSize: 11.5, weight: .heavy),
|
|
|
+ bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
|
|
|
+ bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
|
|
|
+ expTitleFont: .systemFont(ofSize: 14, weight: .heavy),
|
|
|
+ expMetaFont: .systemFont(ofSize: 12, weight: .semibold),
|
|
|
+ eduTitleFont: .systemFont(ofSize: 13.5, weight: .heavy),
|
|
|
+ eduMetaFont: .systemFont(ofSize: 12, weight: .medium),
|
|
|
+ bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
|
|
|
+ bulletMarkerFont: .systemFont(ofSize: 13, weight: .heavy),
|
|
|
+ bulletMarkerColor: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1),
|
|
|
+ ink: NSColor(srgbRed: 32 / 255, green: 26 / 255, blue: 52 / 255, alpha: 1),
|
|
|
+ muted: NSColor(srgbRed: 108 / 255, green: 96 / 255, blue: 130 / 255, alpha: 1),
|
|
|
+ rule: theme.withAlphaComponent(0.22),
|
|
|
+ cardBackground: NSColor(srgbRed: 0.995, green: 0.993, blue: 1, alpha: 1),
|
|
|
+ columnVerticalSpacing: 18,
|
|
|
+ bodyBlockSpacing: 17,
|
|
|
+ roleUsesThemeColor: true,
|
|
|
+ sectionInk: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// Full-width résumé layout that injects `SavedProfile` into the visual language of `CVTemplate`.
|
|
|
+final class CVProfileDocumentView: NSView {
|
|
|
+
|
|
|
+ private let profile: SavedProfile
|
|
|
+ private let template: CVTemplate
|
|
|
+ private let style: DocumentStyle
|
|
|
+
|
|
|
+ init(profile: SavedProfile, template: CVTemplate) {
|
|
|
+ self.profile = profile
|
|
|
+ self.template = template
|
|
|
+ self.style = DocumentStyle.make(for: template)
|
|
|
+ super.init(frame: .zero)
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ wantsLayer = true
|
|
|
+ layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
+ userInterfaceLayoutDirection = .leftToRight
|
|
|
+
|
|
|
+ let card = NSView()
|
|
|
+ card.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ card.wantsLayer = true
|
|
|
+ card.layer?.backgroundColor = style.cardBackground.cgColor
|
|
|
+ card.layer?.cornerRadius = template.family == .executive ? 6 : 10
|
|
|
+ card.layer?.borderWidth = 1
|
|
|
+ card.layer?.borderColor = style.rule.cgColor
|
|
|
+ card.layer?.masksToBounds = true
|
|
|
+
|
|
|
+ let root = buildRoot()
|
|
|
+ root.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ card.addSubview(root)
|
|
|
+
|
|
|
+ addSubview(card)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ card.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
|
+ card.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
|
+ card.topAnchor.constraint(equalTo: topAnchor),
|
|
|
+ card.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
+ card.widthAnchor.constraint(equalToConstant: 640),
|
|
|
+
|
|
|
+ root.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 36),
|
|
|
+ root.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -36),
|
|
|
+ root.topAnchor.constraint(equalTo: card.topAnchor, constant: 32),
|
|
|
+ root.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -36)
|
|
|
+ ])
|
|
|
+ }
|
|
|
+
|
|
|
+ @available(*, unavailable)
|
|
|
+ required init?(coder: NSCoder) {
|
|
|
+ fatalError("init(coder:) has not been implemented")
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: - Composition
|
|
|
+
|
|
|
+ private func buildRoot() -> NSView {
|
|
|
+ switch template.layout {
|
|
|
+ case .singleColumn:
|
|
|
+ return singleColumnLayout()
|
|
|
+ case .twoColumn(let side, let tinted):
|
|
|
+ return twoColumnLayout(sidebar: side, tinted: tinted)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func singleColumnLayout() -> NSView {
|
|
|
+ let v = NSStackView()
|
|
|
+ v.orientation = .vertical
|
|
|
+ v.alignment = .leading
|
|
|
+ v.spacing = style.columnVerticalSpacing + 3
|
|
|
+ v.addArrangedSubview(headerBlock())
|
|
|
+ v.addArrangedSubview(hairline())
|
|
|
+ v.addArrangedSubview(bodyColumn(compact: false))
|
|
|
+ return v
|
|
|
+ }
|
|
|
+
|
|
|
+ private func twoColumnLayout(sidebar: CVTemplate.SidebarSide, tinted: Bool) -> NSView {
|
|
|
+ let v = NSStackView()
|
|
|
+ v.orientation = .vertical
|
|
|
+ v.alignment = .leading
|
|
|
+ v.spacing = style.columnVerticalSpacing + 2
|
|
|
+ v.addArrangedSubview(headerBlock())
|
|
|
+ v.addArrangedSubview(hairline())
|
|
|
+
|
|
|
+ let row = NSStackView()
|
|
|
+ row.orientation = .horizontal
|
|
|
+ row.alignment = .top
|
|
|
+ row.spacing = template.family == .minimal ? 18 : 22
|
|
|
+
|
|
|
+ let sidebarCol = sidebarColumn(tinted: tinted)
|
|
|
+ let mainCol = bodyColumn(compact: true)
|
|
|
+
|
|
|
+ if sidebar == .leading {
|
|
|
+ row.addArrangedSubview(sidebarCol)
|
|
|
+ row.addArrangedSubview(mainCol)
|
|
|
+ } else {
|
|
|
+ row.addArrangedSubview(mainCol)
|
|
|
+ row.addArrangedSubview(sidebarCol)
|
|
|
+ }
|
|
|
+ sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: template.family == .executive ? 0.34 : 0.32).isActive = true
|
|
|
+
|
|
|
+ v.addArrangedSubview(row)
|
|
|
+ return v
|
|
|
+ }
|
|
|
+
|
|
|
+ private func headerBlock() -> NSView {
|
|
|
+ let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
|
|
|
+ let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
|
|
|
+ let contactParts = [profile.personal.email, profile.personal.phone, profile.personal.address].filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
|
+ let contactText = contactParts.isEmpty ? "Add contact details in your profile" : contactParts.joined(separator: " · ")
|
|
|
+
|
|
|
+ let roleColor = style.roleUsesThemeColor ? template.themeColor : style.muted
|
|
|
+ let name = label(nameText, font: style.nameFont, color: style.ink, maxLines: 2)
|
|
|
+ let role = label(roleText, font: style.roleFont, color: roleColor, maxLines: 2)
|
|
|
+ let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.92), maxLines: 3)
|
|
|
+
|
|
|
+ let textCol = NSStackView(views: [name, role, contact])
|
|
|
+ textCol.orientation = .vertical
|
|
|
+ textCol.spacing = template.family == .professional ? 3 : 4
|
|
|
+ textCol.alignment = .leading
|
|
|
+
|
|
|
+ switch template.headline {
|
|
|
+ case .centered:
|
|
|
+ textCol.alignment = .centerX
|
|
|
+ name.alignment = .center
|
|
|
+ role.alignment = .center
|
|
|
+ contact.alignment = .center
|
|
|
+ let accent = headlineAccent()
|
|
|
+ let stack = NSStackView(views: [textCol, accent])
|
|
|
+ stack.orientation = .vertical
|
|
|
+ stack.spacing = 8
|
|
|
+ stack.alignment = .centerX
|
|
|
+ return stack
|
|
|
+ case .avatarStacked:
|
|
|
+ textCol.alignment = .centerX
|
|
|
+ name.alignment = .center
|
|
|
+ role.alignment = .center
|
|
|
+ contact.alignment = .center
|
|
|
+ let accent = headlineAccent()
|
|
|
+ let avatar = initialsBadge(for: nameText)
|
|
|
+ let stack = NSStackView(views: [avatar, textCol, accent])
|
|
|
+ stack.orientation = .vertical
|
|
|
+ stack.spacing = 8
|
|
|
+ stack.alignment = .centerX
|
|
|
+ return stack
|
|
|
+ case .leftAligned, .leftWithInitials:
|
|
|
+ let row = NSStackView()
|
|
|
+ row.orientation = .horizontal
|
|
|
+ row.spacing = 14
|
|
|
+ row.alignment = .centerY
|
|
|
+ row.addArrangedSubview(textCol)
|
|
|
+ if template.headline == .leftWithInitials {
|
|
|
+ row.addArrangedSubview(NSView())
|
|
|
+ row.addArrangedSubview(initialsBadge(for: nameText))
|
|
|
+ }
|
|
|
+ let col = NSStackView(views: [row, headlineAccent()])
|
|
|
+ col.orientation = .vertical
|
|
|
+ col.spacing = 8
|
|
|
+ col.alignment = .leading
|
|
|
+ return col
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func initialsBadge(for fullName: String) -> NSView {
|
|
|
+ let initials = Self.initials(from: fullName)
|
|
|
+ let t = NSTextField(labelWithString: initials)
|
|
|
+ t.font = .systemFont(ofSize: 13, weight: .bold)
|
|
|
+ t.textColor = template.themeColor
|
|
|
+ t.alignment = .center
|
|
|
+ t.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ let wrap = NSView()
|
|
|
+ wrap.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ wrap.wantsLayer = true
|
|
|
+ wrap.layer?.cornerRadius = 22
|
|
|
+ wrap.layer?.borderWidth = 1.5
|
|
|
+ wrap.layer?.borderColor = template.themeColor.withAlphaComponent(0.35).cgColor
|
|
|
+ wrap.addSubview(t)
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
+ wrap.widthAnchor.constraint(equalToConstant: 44),
|
|
|
+ wrap.heightAnchor.constraint(equalToConstant: 44),
|
|
|
+ t.centerXAnchor.constraint(equalTo: wrap.centerXAnchor),
|
|
|
+ t.centerYAnchor.constraint(equalTo: wrap.centerYAnchor)
|
|
|
+ ])
|
|
|
+ return wrap
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func initials(from fullName: String) -> String {
|
|
|
+ let parts = fullName.split(separator: " ").filter { !$0.isEmpty }
|
|
|
+ if parts.count >= 2 {
|
|
|
+ let a = parts[0].prefix(1)
|
|
|
+ let b = parts[1].prefix(1)
|
|
|
+ return "\(a)\(b)".uppercased()
|
|
|
+ }
|
|
|
+ if let first = parts.first { return String(first.prefix(2)).uppercased() }
|
|
|
+ return "CV"
|
|
|
+ }
|
|
|
+
|
|
|
+ private func headlineAccent() -> NSView {
|
|
|
+ let bar = NSView()
|
|
|
+ bar.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ bar.wantsLayer = true
|
|
|
+ switch template.accent {
|
|
|
+ case .none:
|
|
|
+ bar.heightAnchor.constraint(equalToConstant: 1).isActive = true
|
|
|
+ return bar
|
|
|
+ case .redUnderline:
|
|
|
+ bar.layer?.backgroundColor = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).cgColor
|
|
|
+ bar.heightAnchor.constraint(equalToConstant: 2).isActive = true
|
|
|
+ bar.widthAnchor.constraint(equalToConstant: template.family == .minimal ? 140 : 168).isActive = true
|
|
|
+ return bar
|
|
|
+ case .redBar:
|
|
|
+ bar.layer?.backgroundColor = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).cgColor
|
|
|
+ bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
|
|
|
+ bar.widthAnchor.constraint(equalToConstant: 56).isActive = true
|
|
|
+ return bar
|
|
|
+ case .blueBar:
|
|
|
+ bar.layer?.backgroundColor = template.themeColor.cgColor
|
|
|
+ bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
|
|
|
+ bar.widthAnchor.constraint(equalToConstant: template.family == .executive ? 100 : 120).isActive = true
|
|
|
+ return bar
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private func hairline() -> NSView {
|
|
|
+ let v = NSView()
|
|
|
+ v.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ v.wantsLayer = true
|
|
|
+ v.layer?.backgroundColor = style.rule.cgColor
|
|
|
+ let h: CGFloat = template.family == .executive ? 1.5 : 1
|
|
|
+ v.heightAnchor.constraint(equalToConstant: h).isActive = true
|
|
|
+ return v
|
|
|
+ }
|
|
|
+
|
|
|
+ private func sidebarColumn(tinted: Bool) -> NSView {
|
|
|
+ let box = NSStackView()
|
|
|
+ box.orientation = .vertical
|
|
|
+ box.spacing = 12
|
|
|
+ box.alignment = .leading
|
|
|
+ if tinted {
|
|
|
+ box.wantsLayer = true
|
|
|
+ box.layer?.backgroundColor = template.themeColor.withAlphaComponent(template.family == .creative ? 0.12 : 0.08).cgColor
|
|
|
+ box.layer?.cornerRadius = 8
|
|
|
+ }
|
|
|
+ box.edgeInsets = NSEdgeInsets(top: tinted ? 14 : 0, left: tinted ? 14 : 0, bottom: tinted ? 14 : 0, right: tinted ? 14 : 0)
|
|
|
+
|
|
|
+ box.addArrangedSubview(sectionHeading("Contact"))
|
|
|
+ for line in contactLines() {
|
|
|
+ box.addArrangedSubview(label(line, font: style.contactFont, color: style.ink, maxLines: 0))
|
|
|
+ }
|
|
|
+
|
|
|
+ if let skillsBlock = ancillaryBlock(title: "Languages & more", body: combinedAncillaryText()) {
|
|
|
+ box.addArrangedSubview(skillsBlock)
|
|
|
+ }
|
|
|
+
|
|
|
+ return box
|
|
|
+ }
|
|
|
+
|
|
|
+ private func bodyColumn(compact: Bool) -> NSView {
|
|
|
+ let v = NSStackView()
|
|
|
+ v.orientation = .vertical
|
|
|
+ v.spacing = compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2
|
|
|
+ v.alignment = .leading
|
|
|
+
|
|
|
+ if let summary = nonEmpty(profile.careerSummary) {
|
|
|
+ v.addArrangedSubview(sectionHeading("Summary"))
|
|
|
+ v.addArrangedSubview(paragraph(summary, compact: compact))
|
|
|
+ }
|
|
|
+
|
|
|
+ let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
|
|
|
+ if !jobs.isEmpty {
|
|
|
+ v.addArrangedSubview(sectionHeading("Experience"))
|
|
|
+ for job in jobs {
|
|
|
+ v.addArrangedSubview(experienceBlock(job: job, compact: compact))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
|
|
|
+ if !schools.isEmpty {
|
|
|
+ v.addArrangedSubview(sectionHeading("Education"))
|
|
|
+ for edu in schools {
|
|
|
+ v.addArrangedSubview(educationBlock(edu: edu, compact: compact))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if let cert = nonEmpty(profile.certificates) {
|
|
|
+ v.addArrangedSubview(sectionHeading("Certificates"))
|
|
|
+ v.addArrangedSubview(paragraph(cert, compact: compact))
|
|
|
+ }
|
|
|
+ if let interests = nonEmpty(profile.interests) {
|
|
|
+ v.addArrangedSubview(sectionHeading("Interests"))
|
|
|
+ v.addArrangedSubview(paragraph(interests, compact: compact))
|
|
|
+ }
|
|
|
+ if let ref = nonEmpty(profile.referral) {
|
|
|
+ v.addArrangedSubview(sectionHeading("Referrals"))
|
|
|
+ v.addArrangedSubview(paragraph(ref, compact: compact))
|
|
|
+ }
|
|
|
+
|
|
|
+ return v
|
|
|
+ }
|
|
|
+
|
|
|
+ private func ancillaryBlock(title: String, body: String?) -> NSStackView? {
|
|
|
+ guard let body, !body.isEmpty else { return nil }
|
|
|
+ let s = NSStackView()
|
|
|
+ s.orientation = .vertical
|
|
|
+ s.spacing = 6
|
|
|
+ s.alignment = .leading
|
|
|
+ s.addArrangedSubview(sectionHeading(title))
|
|
|
+ s.addArrangedSubview(paragraph(body, compact: true))
|
|
|
+ return s
|
|
|
+ }
|
|
|
+
|
|
|
+ private func contactLines() -> [String] {
|
|
|
+ var lines: [String] = []
|
|
|
+ let p = profile.personal
|
|
|
+ if !p.email.isEmpty { lines.append(p.email) }
|
|
|
+ if !p.phone.isEmpty { lines.append(p.phone) }
|
|
|
+ if !p.address.isEmpty { lines.append(p.address) }
|
|
|
+ return lines.isEmpty ? ["—"] : lines
|
|
|
+ }
|
|
|
+
|
|
|
+ private func combinedAncillaryText() -> String? {
|
|
|
+ let chunks = [profile.languages, profile.interests].map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
|
|
|
+ return chunks.isEmpty ? nil : chunks.joined(separator: "\n\n")
|
|
|
+ }
|
|
|
+
|
|
|
+ private func experienceBlock(job: WorkExperiencePayload, compact: Bool) -> NSView {
|
|
|
+ let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
|
|
|
+ let meta = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ let v = NSStackView()
|
|
|
+ v.orientation = .vertical
|
|
|
+ v.spacing = template.family == .professional ? 4 : 6
|
|
|
+ v.alignment = .leading
|
|
|
+ if !titleLine.isEmpty {
|
|
|
+ v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0))
|
|
|
+ }
|
|
|
+ if !meta.isEmpty {
|
|
|
+ v.addArrangedSubview(label(meta, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
|
|
|
+ }
|
|
|
+ for bullet in Self.bulletChunks(from: job.description) {
|
|
|
+ v.addArrangedSubview(bulletRow(bullet, compact: compact))
|
|
|
+ }
|
|
|
+ return v
|
|
|
+ }
|
|
|
+
|
|
|
+ private func educationBlock(edu: EducationPayload, compact: Bool) -> NSView {
|
|
|
+ let v = NSStackView()
|
|
|
+ v.orientation = .vertical
|
|
|
+ v.spacing = 4
|
|
|
+ v.alignment = .leading
|
|
|
+ let head = [edu.institution, edu.degree].filter { !$0.isEmpty }.joined(separator: " — ")
|
|
|
+ if !head.isEmpty {
|
|
|
+ v.addArrangedSubview(label(head, font: style.eduTitleFont, color: style.ink, maxLines: 0))
|
|
|
+ }
|
|
|
+ if !edu.year.isEmpty {
|
|
|
+ v.addArrangedSubview(label(edu.year, font: style.eduMetaFont, color: style.muted, maxLines: 0))
|
|
|
+ }
|
|
|
+ return v
|
|
|
+ }
|
|
|
+
|
|
|
+ private func bulletRow(_ text: String, compact: Bool) -> NSView {
|
|
|
+ let marker: String = template.family == .minimal ? "·" : "•"
|
|
|
+ let dot = NSTextField(labelWithString: marker)
|
|
|
+ dot.font = style.bulletMarkerFont
|
|
|
+ dot.textColor = style.bulletMarkerColor
|
|
|
+ dot.translatesAutoresizingMaskIntoConstraints = false
|
|
|
+ let bodyFont = compact ? style.bodyCompactFont : style.bulletBodyFont
|
|
|
+ let body = label(text, font: bodyFont, color: style.ink, maxLines: 0)
|
|
|
+ let row = NSStackView(views: [dot, body])
|
|
|
+ row.orientation = .horizontal
|
|
|
+ row.spacing = template.family == .creative ? 10 : 8
|
|
|
+ row.alignment = .top
|
|
|
+ dot.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
+ return row
|
|
|
+ }
|
|
|
+
|
|
|
+ private func paragraph(_ text: String, compact: Bool) -> NSTextField {
|
|
|
+ let font = compact ? style.bodyCompactFont : style.bodyFont
|
|
|
+ return label(text, font: font, color: style.ink, maxLines: 0)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func sectionHeading(_ raw: String) -> NSTextField {
|
|
|
+ let upper = raw.uppercased()
|
|
|
+ let s: String
|
|
|
+ switch template.sectionLabelStyle {
|
|
|
+ case .uppercase: s = upper
|
|
|
+ case .slashed: s = "// \(upper)"
|
|
|
+ case .bracketed: s = "[ \(upper) ]"
|
|
|
+ }
|
|
|
+ let t = NSTextField(labelWithString: s)
|
|
|
+ t.font = style.sectionFont
|
|
|
+ t.textColor = style.sectionInk
|
|
|
+ t.alignment = .left
|
|
|
+ return t
|
|
|
+ }
|
|
|
+
|
|
|
+ private func label(_ string: String, font: NSFont, color: NSColor, maxLines: Int) -> NSTextField {
|
|
|
+ let isWrapping = maxLines == 0
|
|
|
+ let t: NSTextField
|
|
|
+ if isWrapping {
|
|
|
+ t = NSTextField(wrappingLabelWithString: string)
|
|
|
+ t.maximumNumberOfLines = 0
|
|
|
+ } else {
|
|
|
+ t = NSTextField(labelWithString: string)
|
|
|
+ t.maximumNumberOfLines = maxLines
|
|
|
+ }
|
|
|
+ t.font = font
|
|
|
+ t.textColor = color
|
|
|
+ t.alignment = .left
|
|
|
+ return t
|
|
|
+ }
|
|
|
+
|
|
|
+ private func displayable(_ value: String, placeholder: String) -> String {
|
|
|
+ let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ return t.isEmpty ? placeholder : t
|
|
|
+ }
|
|
|
+
|
|
|
+ private func nonEmpty(_ value: String) -> String? {
|
|
|
+ let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ return t.isEmpty ? nil : t
|
|
|
+ }
|
|
|
+
|
|
|
+ private static func bulletChunks(from text: String) -> [String] {
|
|
|
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ if trimmed.isEmpty { return [] }
|
|
|
+ let byNewline = trimmed.components(separatedBy: .newlines)
|
|
|
+ .map { $0.trimmingCharacters(in: .whitespaces) }
|
|
|
+ .filter { !$0.isEmpty }
|
|
|
+ if byNewline.count > 1 { return byNewline }
|
|
|
+ let byBullet = trimmed.split(separator: "•")
|
|
|
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
|
+ .filter { !$0.isEmpty }
|
|
|
+ if byBullet.count > 1 { return byBullet.map { String($0) } }
|
|
|
+ return [trimmed]
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - Payload helpers
|
|
|
+
|
|
|
+private extension WorkExperiencePayload {
|
|
|
+ var isEffectivelyEmpty: Bool {
|
|
|
+ [jobTitle, company, duration, description].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+private extension EducationPayload {
|
|
|
+ var isEffectivelyEmpty: Bool {
|
|
|
+ [degree, institution, year].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
|
+ }
|
|
|
+}
|