|
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+//
|
|
|
2
|
+// CVProfileDocumentView.swift
|
|
|
3
|
+// App for Indeed
|
|
|
4
|
+//
|
|
|
5
|
+// Renders saved profile data in a layout that follows the selected CV template’s
|
|
|
6
|
+// family (professional / modern / minimal / executive / creative), headline,
|
|
|
7
|
+// accent, section labels, and column structure.
|
|
|
8
|
+//
|
|
|
9
|
+
|
|
|
10
|
+import Cocoa
|
|
|
11
|
+
|
|
|
12
|
+/// Typography and chrome derived from `CVTemplate.family` so the filled résumé
|
|
|
13
|
+/// visibly matches the gallery card the user picked—not a single generic layout.
|
|
|
14
|
+private struct DocumentStyle {
|
|
|
15
|
+ let nameFont: NSFont
|
|
|
16
|
+ let roleFont: NSFont
|
|
|
17
|
+ let contactFont: NSFont
|
|
|
18
|
+ let sectionFont: NSFont
|
|
|
19
|
+ let bodyFont: NSFont
|
|
|
20
|
+ let bodyCompactFont: NSFont
|
|
|
21
|
+ let expTitleFont: NSFont
|
|
|
22
|
+ let expMetaFont: NSFont
|
|
|
23
|
+ let eduTitleFont: NSFont
|
|
|
24
|
+ let eduMetaFont: NSFont
|
|
|
25
|
+ let bulletBodyFont: NSFont
|
|
|
26
|
+ let bulletMarkerFont: NSFont
|
|
|
27
|
+ let bulletMarkerColor: NSColor
|
|
|
28
|
+ let ink: NSColor
|
|
|
29
|
+ let muted: NSColor
|
|
|
30
|
+ let rule: NSColor
|
|
|
31
|
+ let cardBackground: NSColor
|
|
|
32
|
+ let columnVerticalSpacing: CGFloat
|
|
|
33
|
+ let bodyBlockSpacing: CGFloat
|
|
|
34
|
+ /// When true, the headline job title uses the template theme color.
|
|
|
35
|
+ let roleUsesThemeColor: Bool
|
|
|
36
|
+ /// Section heading text color (often theme; executive stays conservative).
|
|
|
37
|
+ let sectionInk: NSColor
|
|
|
38
|
+
|
|
|
39
|
+ static func make(for template: CVTemplate) -> DocumentStyle {
|
|
|
40
|
+ let theme = template.themeColor
|
|
|
41
|
+ switch template.family {
|
|
|
42
|
+ case .minimal:
|
|
|
43
|
+ return DocumentStyle(
|
|
|
44
|
+ nameFont: .systemFont(ofSize: 20, weight: .regular),
|
|
|
45
|
+ roleFont: .systemFont(ofSize: 13.5, weight: .regular),
|
|
|
46
|
+ contactFont: .systemFont(ofSize: 11.5, weight: .regular),
|
|
|
47
|
+ sectionFont: .systemFont(ofSize: 10.5, weight: .semibold),
|
|
|
48
|
+ bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
|
|
|
49
|
+ bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
|
|
|
50
|
+ expTitleFont: .systemFont(ofSize: 13, weight: .medium),
|
|
|
51
|
+ expMetaFont: .systemFont(ofSize: 11.5, weight: .regular),
|
|
|
52
|
+ eduTitleFont: .systemFont(ofSize: 13, weight: .medium),
|
|
|
53
|
+ eduMetaFont: .systemFont(ofSize: 11.5, weight: .regular),
|
|
|
54
|
+ bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
|
|
|
55
|
+ bulletMarkerFont: .systemFont(ofSize: 11, weight: .light),
|
|
|
56
|
+ bulletMarkerColor: theme.withAlphaComponent(0.55),
|
|
|
57
|
+ ink: NSColor(srgbRed: 42 / 255, green: 48 / 255, blue: 56 / 255, alpha: 1),
|
|
|
58
|
+ muted: NSColor(srgbRed: 110 / 255, green: 118 / 255, blue: 128 / 255, alpha: 1),
|
|
|
59
|
+ rule: NSColor(srgbRed: 228 / 255, green: 230 / 255, blue: 234 / 255, alpha: 1),
|
|
|
60
|
+ cardBackground: NSColor(srgbRed: 0.998, green: 0.998, blue: 0.998, alpha: 1),
|
|
|
61
|
+ columnVerticalSpacing: 15,
|
|
|
62
|
+ bodyBlockSpacing: 15,
|
|
|
63
|
+ roleUsesThemeColor: false,
|
|
|
64
|
+ sectionInk: theme.withAlphaComponent(0.92)
|
|
|
65
|
+ )
|
|
|
66
|
+
|
|
|
67
|
+ case .professional:
|
|
|
68
|
+ return DocumentStyle(
|
|
|
69
|
+ nameFont: .systemFont(ofSize: 21, weight: .semibold),
|
|
|
70
|
+ roleFont: .systemFont(ofSize: 13.5, weight: .medium),
|
|
|
71
|
+ contactFont: .systemFont(ofSize: 11.5, weight: .regular),
|
|
|
72
|
+ sectionFont: .systemFont(ofSize: 10.5, weight: .heavy),
|
|
|
73
|
+ bodyFont: .systemFont(ofSize: 12, weight: .regular),
|
|
|
74
|
+ bodyCompactFont: .systemFont(ofSize: 11.5, weight: .regular),
|
|
|
75
|
+ expTitleFont: .systemFont(ofSize: 13.5, weight: .semibold),
|
|
|
76
|
+ expMetaFont: .systemFont(ofSize: 11.5, weight: .semibold),
|
|
|
77
|
+ eduTitleFont: .systemFont(ofSize: 13, weight: .semibold),
|
|
|
78
|
+ eduMetaFont: .systemFont(ofSize: 11.5, weight: .medium),
|
|
|
79
|
+ bulletBodyFont: .systemFont(ofSize: 12, weight: .regular),
|
|
|
80
|
+ bulletMarkerFont: .systemFont(ofSize: 12, weight: .bold),
|
|
|
81
|
+ bulletMarkerColor: NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.55),
|
|
|
82
|
+ ink: NSColor(srgbRed: 28 / 255, green: 36 / 255, blue: 48 / 255, alpha: 1),
|
|
|
83
|
+ muted: NSColor(srgbRed: 88 / 255, green: 98 / 255, blue: 118 / 255, alpha: 1),
|
|
|
84
|
+ rule: NSColor(srgbRed: 210 / 255, green: 218 / 255, blue: 232 / 255, alpha: 1),
|
|
|
85
|
+ cardBackground: NSColor.white,
|
|
|
86
|
+ columnVerticalSpacing: 13,
|
|
|
87
|
+ bodyBlockSpacing: 13,
|
|
|
88
|
+ roleUsesThemeColor: false,
|
|
|
89
|
+ sectionInk: theme
|
|
|
90
|
+ )
|
|
|
91
|
+
|
|
|
92
|
+ case .modern:
|
|
|
93
|
+ return DocumentStyle(
|
|
|
94
|
+ nameFont: .systemFont(ofSize: 22, weight: .bold),
|
|
|
95
|
+ roleFont: .systemFont(ofSize: 14, weight: .semibold),
|
|
|
96
|
+ contactFont: .systemFont(ofSize: 12, weight: .regular),
|
|
|
97
|
+ sectionFont: .systemFont(ofSize: 11, weight: .heavy),
|
|
|
98
|
+ bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
|
|
|
99
|
+ bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
|
|
|
100
|
+ expTitleFont: .systemFont(ofSize: 14, weight: .bold),
|
|
|
101
|
+ expMetaFont: .systemFont(ofSize: 12, weight: .medium),
|
|
|
102
|
+ eduTitleFont: .systemFont(ofSize: 13.5, weight: .bold),
|
|
|
103
|
+ eduMetaFont: .systemFont(ofSize: 12, weight: .regular),
|
|
|
104
|
+ bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
|
|
|
105
|
+ bulletMarkerFont: .systemFont(ofSize: 13, weight: .bold),
|
|
|
106
|
+ bulletMarkerColor: theme,
|
|
|
107
|
+ ink: NSColor(srgbRed: 24 / 255, green: 34 / 255, blue: 52 / 255, alpha: 1),
|
|
|
108
|
+ muted: NSColor(srgbRed: 96 / 255, green: 110 / 255, blue: 132 / 255, alpha: 1),
|
|
|
109
|
+ rule: NSColor(srgbRed: 200 / 255, green: 214 / 255, blue: 236 / 255, alpha: 1),
|
|
|
110
|
+ cardBackground: NSColor(srgbRed: 0.99, green: 0.995, blue: 1, alpha: 1),
|
|
|
111
|
+ columnVerticalSpacing: 17,
|
|
|
112
|
+ bodyBlockSpacing: 16,
|
|
|
113
|
+ roleUsesThemeColor: true,
|
|
|
114
|
+ sectionInk: theme
|
|
|
115
|
+ )
|
|
|
116
|
+
|
|
|
117
|
+ case .executive:
|
|
|
118
|
+ let serifName = NSFont(name: "Georgia-Bold", size: 23) ?? .systemFont(ofSize: 23, weight: .semibold)
|
|
|
119
|
+ let serifRole = NSFont(name: "Georgia", size: 14) ?? .systemFont(ofSize: 14, weight: .regular)
|
|
|
120
|
+ let serifBody = NSFont(name: "Georgia", size: 12.5) ?? .systemFont(ofSize: 12.5, weight: .regular)
|
|
|
121
|
+ let serifCompact = NSFont(name: "Georgia", size: 12) ?? .systemFont(ofSize: 12, weight: .regular)
|
|
|
122
|
+ let georgia12 = NSFont(name: "Georgia", size: 12) ?? .systemFont(ofSize: 12)
|
|
|
123
|
+ let georgia115 = NSFont(name: "Georgia", size: 11.5) ?? .systemFont(ofSize: 11.5)
|
|
|
124
|
+ let expMeta = NSFont(name: "Georgia-Italic", size: 12)
|
|
|
125
|
+ ?? NSFontManager.shared.convert(georgia12, toHaveTrait: .italicFontMask)
|
|
|
126
|
+ let eduMeta = NSFont(name: "Georgia-Italic", size: 11.5)
|
|
|
127
|
+ ?? NSFontManager.shared.convert(georgia115, toHaveTrait: .italicFontMask)
|
|
|
128
|
+ return DocumentStyle(
|
|
|
129
|
+ nameFont: serifName,
|
|
|
130
|
+ roleFont: serifRole,
|
|
|
131
|
+ contactFont: NSFont(name: "Georgia", size: 11.5) ?? .systemFont(ofSize: 11.5),
|
|
|
132
|
+ sectionFont: .systemFont(ofSize: 10.5, weight: .heavy),
|
|
|
133
|
+ bodyFont: serifBody,
|
|
|
134
|
+ bodyCompactFont: serifCompact,
|
|
|
135
|
+ expTitleFont: NSFont(name: "Georgia-Bold", size: 14) ?? .systemFont(ofSize: 14, weight: .semibold),
|
|
|
136
|
+ expMetaFont: expMeta,
|
|
|
137
|
+ eduTitleFont: NSFont(name: "Georgia-Bold", size: 13.5) ?? .systemFont(ofSize: 13.5, weight: .semibold),
|
|
|
138
|
+ eduMetaFont: eduMeta,
|
|
|
139
|
+ bulletBodyFont: serifCompact,
|
|
|
140
|
+ bulletMarkerFont: .systemFont(ofSize: 11, weight: .bold),
|
|
|
141
|
+ bulletMarkerColor: NSColor(srgbRed: 55 / 255, green: 55 / 255, blue: 62 / 255, alpha: 1),
|
|
|
142
|
+ ink: NSColor(srgbRed: 22 / 255, green: 22 / 255, blue: 28 / 255, alpha: 1),
|
|
|
143
|
+ muted: NSColor(srgbRed: 82 / 255, green: 82 / 255, blue: 90 / 255, alpha: 1),
|
|
|
144
|
+ rule: NSColor(srgbRed: 72 / 255, green: 72 / 255, blue: 78 / 255, alpha: 0.35),
|
|
|
145
|
+ cardBackground: NSColor(srgbRed: 0.992, green: 0.99, blue: 0.985, alpha: 1),
|
|
|
146
|
+ columnVerticalSpacing: 18,
|
|
|
147
|
+ bodyBlockSpacing: 17,
|
|
|
148
|
+ roleUsesThemeColor: false,
|
|
|
149
|
+ sectionInk: NSColor(srgbRed: 32 / 255, green: 32 / 255, blue: 38 / 255, alpha: 1)
|
|
|
150
|
+ )
|
|
|
151
|
+
|
|
|
152
|
+ case .creative:
|
|
|
153
|
+ return DocumentStyle(
|
|
|
154
|
+ nameFont: .systemFont(ofSize: 23, weight: .heavy),
|
|
|
155
|
+ roleFont: .systemFont(ofSize: 14, weight: .semibold),
|
|
|
156
|
+ contactFont: .systemFont(ofSize: 11.5, weight: .medium),
|
|
|
157
|
+ sectionFont: .systemFont(ofSize: 11.5, weight: .heavy),
|
|
|
158
|
+ bodyFont: .systemFont(ofSize: 12.5, weight: .regular),
|
|
|
159
|
+ bodyCompactFont: .systemFont(ofSize: 12, weight: .regular),
|
|
|
160
|
+ expTitleFont: .systemFont(ofSize: 14, weight: .heavy),
|
|
|
161
|
+ expMetaFont: .systemFont(ofSize: 12, weight: .semibold),
|
|
|
162
|
+ eduTitleFont: .systemFont(ofSize: 13.5, weight: .heavy),
|
|
|
163
|
+ eduMetaFont: .systemFont(ofSize: 12, weight: .medium),
|
|
|
164
|
+ bulletBodyFont: .systemFont(ofSize: 12.5, weight: .regular),
|
|
|
165
|
+ bulletMarkerFont: .systemFont(ofSize: 13, weight: .heavy),
|
|
|
166
|
+ bulletMarkerColor: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1),
|
|
|
167
|
+ ink: NSColor(srgbRed: 32 / 255, green: 26 / 255, blue: 52 / 255, alpha: 1),
|
|
|
168
|
+ muted: NSColor(srgbRed: 108 / 255, green: 96 / 255, blue: 130 / 255, alpha: 1),
|
|
|
169
|
+ rule: theme.withAlphaComponent(0.22),
|
|
|
170
|
+ cardBackground: NSColor(srgbRed: 0.995, green: 0.993, blue: 1, alpha: 1),
|
|
|
171
|
+ columnVerticalSpacing: 18,
|
|
|
172
|
+ bodyBlockSpacing: 17,
|
|
|
173
|
+ roleUsesThemeColor: true,
|
|
|
174
|
+ sectionInk: NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1)
|
|
|
175
|
+ )
|
|
|
176
|
+ }
|
|
|
177
|
+ }
|
|
|
178
|
+}
|
|
|
179
|
+
|
|
|
180
|
+/// Full-width résumé layout that injects `SavedProfile` into the visual language of `CVTemplate`.
|
|
|
181
|
+final class CVProfileDocumentView: NSView {
|
|
|
182
|
+
|
|
|
183
|
+ private let profile: SavedProfile
|
|
|
184
|
+ private let template: CVTemplate
|
|
|
185
|
+ private let style: DocumentStyle
|
|
|
186
|
+
|
|
|
187
|
+ init(profile: SavedProfile, template: CVTemplate) {
|
|
|
188
|
+ self.profile = profile
|
|
|
189
|
+ self.template = template
|
|
|
190
|
+ self.style = DocumentStyle.make(for: template)
|
|
|
191
|
+ super.init(frame: .zero)
|
|
|
192
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
193
|
+ wantsLayer = true
|
|
|
194
|
+ layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
195
|
+ userInterfaceLayoutDirection = .leftToRight
|
|
|
196
|
+
|
|
|
197
|
+ let card = NSView()
|
|
|
198
|
+ card.translatesAutoresizingMaskIntoConstraints = false
|
|
|
199
|
+ card.wantsLayer = true
|
|
|
200
|
+ card.layer?.backgroundColor = style.cardBackground.cgColor
|
|
|
201
|
+ card.layer?.cornerRadius = template.family == .executive ? 6 : 10
|
|
|
202
|
+ card.layer?.borderWidth = 1
|
|
|
203
|
+ card.layer?.borderColor = style.rule.cgColor
|
|
|
204
|
+ card.layer?.masksToBounds = true
|
|
|
205
|
+
|
|
|
206
|
+ let root = buildRoot()
|
|
|
207
|
+ root.translatesAutoresizingMaskIntoConstraints = false
|
|
|
208
|
+ card.addSubview(root)
|
|
|
209
|
+
|
|
|
210
|
+ addSubview(card)
|
|
|
211
|
+ NSLayoutConstraint.activate([
|
|
|
212
|
+ card.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
|
213
|
+ card.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
|
214
|
+ card.topAnchor.constraint(equalTo: topAnchor),
|
|
|
215
|
+ card.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
216
|
+ card.widthAnchor.constraint(equalToConstant: 640),
|
|
|
217
|
+
|
|
|
218
|
+ root.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 36),
|
|
|
219
|
+ root.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -36),
|
|
|
220
|
+ root.topAnchor.constraint(equalTo: card.topAnchor, constant: 32),
|
|
|
221
|
+ root.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -36)
|
|
|
222
|
+ ])
|
|
|
223
|
+ }
|
|
|
224
|
+
|
|
|
225
|
+ @available(*, unavailable)
|
|
|
226
|
+ required init?(coder: NSCoder) {
|
|
|
227
|
+ fatalError("init(coder:) has not been implemented")
|
|
|
228
|
+ }
|
|
|
229
|
+
|
|
|
230
|
+ // MARK: - Composition
|
|
|
231
|
+
|
|
|
232
|
+ private func buildRoot() -> NSView {
|
|
|
233
|
+ switch template.layout {
|
|
|
234
|
+ case .singleColumn:
|
|
|
235
|
+ return singleColumnLayout()
|
|
|
236
|
+ case .twoColumn(let side, let tinted):
|
|
|
237
|
+ return twoColumnLayout(sidebar: side, tinted: tinted)
|
|
|
238
|
+ }
|
|
|
239
|
+ }
|
|
|
240
|
+
|
|
|
241
|
+ private func singleColumnLayout() -> NSView {
|
|
|
242
|
+ let v = NSStackView()
|
|
|
243
|
+ v.orientation = .vertical
|
|
|
244
|
+ v.alignment = .leading
|
|
|
245
|
+ v.spacing = style.columnVerticalSpacing + 3
|
|
|
246
|
+ v.addArrangedSubview(headerBlock())
|
|
|
247
|
+ v.addArrangedSubview(hairline())
|
|
|
248
|
+ v.addArrangedSubview(bodyColumn(compact: false))
|
|
|
249
|
+ return v
|
|
|
250
|
+ }
|
|
|
251
|
+
|
|
|
252
|
+ private func twoColumnLayout(sidebar: CVTemplate.SidebarSide, tinted: Bool) -> NSView {
|
|
|
253
|
+ let v = NSStackView()
|
|
|
254
|
+ v.orientation = .vertical
|
|
|
255
|
+ v.alignment = .leading
|
|
|
256
|
+ v.spacing = style.columnVerticalSpacing + 2
|
|
|
257
|
+ v.addArrangedSubview(headerBlock())
|
|
|
258
|
+ v.addArrangedSubview(hairline())
|
|
|
259
|
+
|
|
|
260
|
+ let row = NSStackView()
|
|
|
261
|
+ row.orientation = .horizontal
|
|
|
262
|
+ row.alignment = .top
|
|
|
263
|
+ row.spacing = template.family == .minimal ? 18 : 22
|
|
|
264
|
+
|
|
|
265
|
+ let sidebarCol = sidebarColumn(tinted: tinted)
|
|
|
266
|
+ let mainCol = bodyColumn(compact: true)
|
|
|
267
|
+
|
|
|
268
|
+ if sidebar == .leading {
|
|
|
269
|
+ row.addArrangedSubview(sidebarCol)
|
|
|
270
|
+ row.addArrangedSubview(mainCol)
|
|
|
271
|
+ } else {
|
|
|
272
|
+ row.addArrangedSubview(mainCol)
|
|
|
273
|
+ row.addArrangedSubview(sidebarCol)
|
|
|
274
|
+ }
|
|
|
275
|
+ sidebarCol.widthAnchor.constraint(equalTo: row.widthAnchor, multiplier: template.family == .executive ? 0.34 : 0.32).isActive = true
|
|
|
276
|
+
|
|
|
277
|
+ v.addArrangedSubview(row)
|
|
|
278
|
+ return v
|
|
|
279
|
+ }
|
|
|
280
|
+
|
|
|
281
|
+ private func headerBlock() -> NSView {
|
|
|
282
|
+ let nameText = displayable(profile.personal.fullName, placeholder: "Your name")
|
|
|
283
|
+ let roleText = displayable(profile.personal.jobTitle, placeholder: "Professional headline")
|
|
|
284
|
+ let contactParts = [profile.personal.email, profile.personal.phone, profile.personal.address].filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
|
285
|
+ let contactText = contactParts.isEmpty ? "Add contact details in your profile" : contactParts.joined(separator: " · ")
|
|
|
286
|
+
|
|
|
287
|
+ let roleColor = style.roleUsesThemeColor ? template.themeColor : style.muted
|
|
|
288
|
+ let name = label(nameText, font: style.nameFont, color: style.ink, maxLines: 2)
|
|
|
289
|
+ let role = label(roleText, font: style.roleFont, color: roleColor, maxLines: 2)
|
|
|
290
|
+ let contact = label(contactText, font: style.contactFont, color: style.muted.withAlphaComponent(0.92), maxLines: 3)
|
|
|
291
|
+
|
|
|
292
|
+ let textCol = NSStackView(views: [name, role, contact])
|
|
|
293
|
+ textCol.orientation = .vertical
|
|
|
294
|
+ textCol.spacing = template.family == .professional ? 3 : 4
|
|
|
295
|
+ textCol.alignment = .leading
|
|
|
296
|
+
|
|
|
297
|
+ switch template.headline {
|
|
|
298
|
+ case .centered:
|
|
|
299
|
+ textCol.alignment = .centerX
|
|
|
300
|
+ name.alignment = .center
|
|
|
301
|
+ role.alignment = .center
|
|
|
302
|
+ contact.alignment = .center
|
|
|
303
|
+ let accent = headlineAccent()
|
|
|
304
|
+ let stack = NSStackView(views: [textCol, accent])
|
|
|
305
|
+ stack.orientation = .vertical
|
|
|
306
|
+ stack.spacing = 8
|
|
|
307
|
+ stack.alignment = .centerX
|
|
|
308
|
+ return stack
|
|
|
309
|
+ case .avatarStacked:
|
|
|
310
|
+ textCol.alignment = .centerX
|
|
|
311
|
+ name.alignment = .center
|
|
|
312
|
+ role.alignment = .center
|
|
|
313
|
+ contact.alignment = .center
|
|
|
314
|
+ let accent = headlineAccent()
|
|
|
315
|
+ let avatar = initialsBadge(for: nameText)
|
|
|
316
|
+ let stack = NSStackView(views: [avatar, textCol, accent])
|
|
|
317
|
+ stack.orientation = .vertical
|
|
|
318
|
+ stack.spacing = 8
|
|
|
319
|
+ stack.alignment = .centerX
|
|
|
320
|
+ return stack
|
|
|
321
|
+ case .leftAligned, .leftWithInitials:
|
|
|
322
|
+ let row = NSStackView()
|
|
|
323
|
+ row.orientation = .horizontal
|
|
|
324
|
+ row.spacing = 14
|
|
|
325
|
+ row.alignment = .centerY
|
|
|
326
|
+ row.addArrangedSubview(textCol)
|
|
|
327
|
+ if template.headline == .leftWithInitials {
|
|
|
328
|
+ row.addArrangedSubview(NSView())
|
|
|
329
|
+ row.addArrangedSubview(initialsBadge(for: nameText))
|
|
|
330
|
+ }
|
|
|
331
|
+ let col = NSStackView(views: [row, headlineAccent()])
|
|
|
332
|
+ col.orientation = .vertical
|
|
|
333
|
+ col.spacing = 8
|
|
|
334
|
+ col.alignment = .leading
|
|
|
335
|
+ return col
|
|
|
336
|
+ }
|
|
|
337
|
+ }
|
|
|
338
|
+
|
|
|
339
|
+ private func initialsBadge(for fullName: String) -> NSView {
|
|
|
340
|
+ let initials = Self.initials(from: fullName)
|
|
|
341
|
+ let t = NSTextField(labelWithString: initials)
|
|
|
342
|
+ t.font = .systemFont(ofSize: 13, weight: .bold)
|
|
|
343
|
+ t.textColor = template.themeColor
|
|
|
344
|
+ t.alignment = .center
|
|
|
345
|
+ t.translatesAutoresizingMaskIntoConstraints = false
|
|
|
346
|
+ let wrap = NSView()
|
|
|
347
|
+ wrap.translatesAutoresizingMaskIntoConstraints = false
|
|
|
348
|
+ wrap.wantsLayer = true
|
|
|
349
|
+ wrap.layer?.cornerRadius = 22
|
|
|
350
|
+ wrap.layer?.borderWidth = 1.5
|
|
|
351
|
+ wrap.layer?.borderColor = template.themeColor.withAlphaComponent(0.35).cgColor
|
|
|
352
|
+ wrap.addSubview(t)
|
|
|
353
|
+ NSLayoutConstraint.activate([
|
|
|
354
|
+ wrap.widthAnchor.constraint(equalToConstant: 44),
|
|
|
355
|
+ wrap.heightAnchor.constraint(equalToConstant: 44),
|
|
|
356
|
+ t.centerXAnchor.constraint(equalTo: wrap.centerXAnchor),
|
|
|
357
|
+ t.centerYAnchor.constraint(equalTo: wrap.centerYAnchor)
|
|
|
358
|
+ ])
|
|
|
359
|
+ return wrap
|
|
|
360
|
+ }
|
|
|
361
|
+
|
|
|
362
|
+ private static func initials(from fullName: String) -> String {
|
|
|
363
|
+ let parts = fullName.split(separator: " ").filter { !$0.isEmpty }
|
|
|
364
|
+ if parts.count >= 2 {
|
|
|
365
|
+ let a = parts[0].prefix(1)
|
|
|
366
|
+ let b = parts[1].prefix(1)
|
|
|
367
|
+ return "\(a)\(b)".uppercased()
|
|
|
368
|
+ }
|
|
|
369
|
+ if let first = parts.first { return String(first.prefix(2)).uppercased() }
|
|
|
370
|
+ return "CV"
|
|
|
371
|
+ }
|
|
|
372
|
+
|
|
|
373
|
+ private func headlineAccent() -> NSView {
|
|
|
374
|
+ let bar = NSView()
|
|
|
375
|
+ bar.translatesAutoresizingMaskIntoConstraints = false
|
|
|
376
|
+ bar.wantsLayer = true
|
|
|
377
|
+ switch template.accent {
|
|
|
378
|
+ case .none:
|
|
|
379
|
+ bar.heightAnchor.constraint(equalToConstant: 1).isActive = true
|
|
|
380
|
+ return bar
|
|
|
381
|
+ case .redUnderline:
|
|
|
382
|
+ bar.layer?.backgroundColor = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).cgColor
|
|
|
383
|
+ bar.heightAnchor.constraint(equalToConstant: 2).isActive = true
|
|
|
384
|
+ bar.widthAnchor.constraint(equalToConstant: template.family == .minimal ? 140 : 168).isActive = true
|
|
|
385
|
+ return bar
|
|
|
386
|
+ case .redBar:
|
|
|
387
|
+ bar.layer?.backgroundColor = NSColor(srgbRed: 207 / 255, green: 67 / 255, blue: 50 / 255, alpha: 1).cgColor
|
|
|
388
|
+ bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
|
|
|
389
|
+ bar.widthAnchor.constraint(equalToConstant: 56).isActive = true
|
|
|
390
|
+ return bar
|
|
|
391
|
+ case .blueBar:
|
|
|
392
|
+ bar.layer?.backgroundColor = template.themeColor.cgColor
|
|
|
393
|
+ bar.heightAnchor.constraint(equalToConstant: 4).isActive = true
|
|
|
394
|
+ bar.widthAnchor.constraint(equalToConstant: template.family == .executive ? 100 : 120).isActive = true
|
|
|
395
|
+ return bar
|
|
|
396
|
+ }
|
|
|
397
|
+ }
|
|
|
398
|
+
|
|
|
399
|
+ private func hairline() -> NSView {
|
|
|
400
|
+ let v = NSView()
|
|
|
401
|
+ v.translatesAutoresizingMaskIntoConstraints = false
|
|
|
402
|
+ v.wantsLayer = true
|
|
|
403
|
+ v.layer?.backgroundColor = style.rule.cgColor
|
|
|
404
|
+ let h: CGFloat = template.family == .executive ? 1.5 : 1
|
|
|
405
|
+ v.heightAnchor.constraint(equalToConstant: h).isActive = true
|
|
|
406
|
+ return v
|
|
|
407
|
+ }
|
|
|
408
|
+
|
|
|
409
|
+ private func sidebarColumn(tinted: Bool) -> NSView {
|
|
|
410
|
+ let box = NSStackView()
|
|
|
411
|
+ box.orientation = .vertical
|
|
|
412
|
+ box.spacing = 12
|
|
|
413
|
+ box.alignment = .leading
|
|
|
414
|
+ if tinted {
|
|
|
415
|
+ box.wantsLayer = true
|
|
|
416
|
+ box.layer?.backgroundColor = template.themeColor.withAlphaComponent(template.family == .creative ? 0.12 : 0.08).cgColor
|
|
|
417
|
+ box.layer?.cornerRadius = 8
|
|
|
418
|
+ }
|
|
|
419
|
+ box.edgeInsets = NSEdgeInsets(top: tinted ? 14 : 0, left: tinted ? 14 : 0, bottom: tinted ? 14 : 0, right: tinted ? 14 : 0)
|
|
|
420
|
+
|
|
|
421
|
+ box.addArrangedSubview(sectionHeading("Contact"))
|
|
|
422
|
+ for line in contactLines() {
|
|
|
423
|
+ box.addArrangedSubview(label(line, font: style.contactFont, color: style.ink, maxLines: 0))
|
|
|
424
|
+ }
|
|
|
425
|
+
|
|
|
426
|
+ if let skillsBlock = ancillaryBlock(title: "Languages & more", body: combinedAncillaryText()) {
|
|
|
427
|
+ box.addArrangedSubview(skillsBlock)
|
|
|
428
|
+ }
|
|
|
429
|
+
|
|
|
430
|
+ return box
|
|
|
431
|
+ }
|
|
|
432
|
+
|
|
|
433
|
+ private func bodyColumn(compact: Bool) -> NSView {
|
|
|
434
|
+ let v = NSStackView()
|
|
|
435
|
+ v.orientation = .vertical
|
|
|
436
|
+ v.spacing = compact ? style.bodyBlockSpacing : style.bodyBlockSpacing + 2
|
|
|
437
|
+ v.alignment = .leading
|
|
|
438
|
+
|
|
|
439
|
+ if let summary = nonEmpty(profile.careerSummary) {
|
|
|
440
|
+ v.addArrangedSubview(sectionHeading("Summary"))
|
|
|
441
|
+ v.addArrangedSubview(paragraph(summary, compact: compact))
|
|
|
442
|
+ }
|
|
|
443
|
+
|
|
|
444
|
+ let jobs = profile.workExperiences.filter { !$0.isEffectivelyEmpty }
|
|
|
445
|
+ if !jobs.isEmpty {
|
|
|
446
|
+ v.addArrangedSubview(sectionHeading("Experience"))
|
|
|
447
|
+ for job in jobs {
|
|
|
448
|
+ v.addArrangedSubview(experienceBlock(job: job, compact: compact))
|
|
|
449
|
+ }
|
|
|
450
|
+ }
|
|
|
451
|
+
|
|
|
452
|
+ let schools = profile.educations.filter { !$0.isEffectivelyEmpty }
|
|
|
453
|
+ if !schools.isEmpty {
|
|
|
454
|
+ v.addArrangedSubview(sectionHeading("Education"))
|
|
|
455
|
+ for edu in schools {
|
|
|
456
|
+ v.addArrangedSubview(educationBlock(edu: edu, compact: compact))
|
|
|
457
|
+ }
|
|
|
458
|
+ }
|
|
|
459
|
+
|
|
|
460
|
+ if let cert = nonEmpty(profile.certificates) {
|
|
|
461
|
+ v.addArrangedSubview(sectionHeading("Certificates"))
|
|
|
462
|
+ v.addArrangedSubview(paragraph(cert, compact: compact))
|
|
|
463
|
+ }
|
|
|
464
|
+ if let interests = nonEmpty(profile.interests) {
|
|
|
465
|
+ v.addArrangedSubview(sectionHeading("Interests"))
|
|
|
466
|
+ v.addArrangedSubview(paragraph(interests, compact: compact))
|
|
|
467
|
+ }
|
|
|
468
|
+ if let ref = nonEmpty(profile.referral) {
|
|
|
469
|
+ v.addArrangedSubview(sectionHeading("Referrals"))
|
|
|
470
|
+ v.addArrangedSubview(paragraph(ref, compact: compact))
|
|
|
471
|
+ }
|
|
|
472
|
+
|
|
|
473
|
+ return v
|
|
|
474
|
+ }
|
|
|
475
|
+
|
|
|
476
|
+ private func ancillaryBlock(title: String, body: String?) -> NSStackView? {
|
|
|
477
|
+ guard let body, !body.isEmpty else { return nil }
|
|
|
478
|
+ let s = NSStackView()
|
|
|
479
|
+ s.orientation = .vertical
|
|
|
480
|
+ s.spacing = 6
|
|
|
481
|
+ s.alignment = .leading
|
|
|
482
|
+ s.addArrangedSubview(sectionHeading(title))
|
|
|
483
|
+ s.addArrangedSubview(paragraph(body, compact: true))
|
|
|
484
|
+ return s
|
|
|
485
|
+ }
|
|
|
486
|
+
|
|
|
487
|
+ private func contactLines() -> [String] {
|
|
|
488
|
+ var lines: [String] = []
|
|
|
489
|
+ let p = profile.personal
|
|
|
490
|
+ if !p.email.isEmpty { lines.append(p.email) }
|
|
|
491
|
+ if !p.phone.isEmpty { lines.append(p.phone) }
|
|
|
492
|
+ if !p.address.isEmpty { lines.append(p.address) }
|
|
|
493
|
+ return lines.isEmpty ? ["—"] : lines
|
|
|
494
|
+ }
|
|
|
495
|
+
|
|
|
496
|
+ private func combinedAncillaryText() -> String? {
|
|
|
497
|
+ let chunks = [profile.languages, profile.interests].map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
|
|
|
498
|
+ return chunks.isEmpty ? nil : chunks.joined(separator: "\n\n")
|
|
|
499
|
+ }
|
|
|
500
|
+
|
|
|
501
|
+ private func experienceBlock(job: WorkExperiencePayload, compact: Bool) -> NSView {
|
|
|
502
|
+ let titleLine = [job.jobTitle, job.company].filter { !$0.isEmpty }.joined(separator: " — ")
|
|
|
503
|
+ let meta = job.duration.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
504
|
+ let v = NSStackView()
|
|
|
505
|
+ v.orientation = .vertical
|
|
|
506
|
+ v.spacing = template.family == .professional ? 4 : 6
|
|
|
507
|
+ v.alignment = .leading
|
|
|
508
|
+ if !titleLine.isEmpty {
|
|
|
509
|
+ v.addArrangedSubview(label(titleLine, font: style.expTitleFont, color: style.ink, maxLines: 0))
|
|
|
510
|
+ }
|
|
|
511
|
+ if !meta.isEmpty {
|
|
|
512
|
+ v.addArrangedSubview(label(meta, font: style.expMetaFont, color: template.themeColor, maxLines: 0))
|
|
|
513
|
+ }
|
|
|
514
|
+ for bullet in Self.bulletChunks(from: job.description) {
|
|
|
515
|
+ v.addArrangedSubview(bulletRow(bullet, compact: compact))
|
|
|
516
|
+ }
|
|
|
517
|
+ return v
|
|
|
518
|
+ }
|
|
|
519
|
+
|
|
|
520
|
+ private func educationBlock(edu: EducationPayload, compact: Bool) -> NSView {
|
|
|
521
|
+ let v = NSStackView()
|
|
|
522
|
+ v.orientation = .vertical
|
|
|
523
|
+ v.spacing = 4
|
|
|
524
|
+ v.alignment = .leading
|
|
|
525
|
+ let head = [edu.institution, edu.degree].filter { !$0.isEmpty }.joined(separator: " — ")
|
|
|
526
|
+ if !head.isEmpty {
|
|
|
527
|
+ v.addArrangedSubview(label(head, font: style.eduTitleFont, color: style.ink, maxLines: 0))
|
|
|
528
|
+ }
|
|
|
529
|
+ if !edu.year.isEmpty {
|
|
|
530
|
+ v.addArrangedSubview(label(edu.year, font: style.eduMetaFont, color: style.muted, maxLines: 0))
|
|
|
531
|
+ }
|
|
|
532
|
+ return v
|
|
|
533
|
+ }
|
|
|
534
|
+
|
|
|
535
|
+ private func bulletRow(_ text: String, compact: Bool) -> NSView {
|
|
|
536
|
+ let marker: String = template.family == .minimal ? "·" : "•"
|
|
|
537
|
+ let dot = NSTextField(labelWithString: marker)
|
|
|
538
|
+ dot.font = style.bulletMarkerFont
|
|
|
539
|
+ dot.textColor = style.bulletMarkerColor
|
|
|
540
|
+ dot.translatesAutoresizingMaskIntoConstraints = false
|
|
|
541
|
+ let bodyFont = compact ? style.bodyCompactFont : style.bulletBodyFont
|
|
|
542
|
+ let body = label(text, font: bodyFont, color: style.ink, maxLines: 0)
|
|
|
543
|
+ let row = NSStackView(views: [dot, body])
|
|
|
544
|
+ row.orientation = .horizontal
|
|
|
545
|
+ row.spacing = template.family == .creative ? 10 : 8
|
|
|
546
|
+ row.alignment = .top
|
|
|
547
|
+ dot.setContentHuggingPriority(.required, for: .horizontal)
|
|
|
548
|
+ return row
|
|
|
549
|
+ }
|
|
|
550
|
+
|
|
|
551
|
+ private func paragraph(_ text: String, compact: Bool) -> NSTextField {
|
|
|
552
|
+ let font = compact ? style.bodyCompactFont : style.bodyFont
|
|
|
553
|
+ return label(text, font: font, color: style.ink, maxLines: 0)
|
|
|
554
|
+ }
|
|
|
555
|
+
|
|
|
556
|
+ private func sectionHeading(_ raw: String) -> NSTextField {
|
|
|
557
|
+ let upper = raw.uppercased()
|
|
|
558
|
+ let s: String
|
|
|
559
|
+ switch template.sectionLabelStyle {
|
|
|
560
|
+ case .uppercase: s = upper
|
|
|
561
|
+ case .slashed: s = "// \(upper)"
|
|
|
562
|
+ case .bracketed: s = "[ \(upper) ]"
|
|
|
563
|
+ }
|
|
|
564
|
+ let t = NSTextField(labelWithString: s)
|
|
|
565
|
+ t.font = style.sectionFont
|
|
|
566
|
+ t.textColor = style.sectionInk
|
|
|
567
|
+ t.alignment = .left
|
|
|
568
|
+ return t
|
|
|
569
|
+ }
|
|
|
570
|
+
|
|
|
571
|
+ private func label(_ string: String, font: NSFont, color: NSColor, maxLines: Int) -> NSTextField {
|
|
|
572
|
+ let isWrapping = maxLines == 0
|
|
|
573
|
+ let t: NSTextField
|
|
|
574
|
+ if isWrapping {
|
|
|
575
|
+ t = NSTextField(wrappingLabelWithString: string)
|
|
|
576
|
+ t.maximumNumberOfLines = 0
|
|
|
577
|
+ } else {
|
|
|
578
|
+ t = NSTextField(labelWithString: string)
|
|
|
579
|
+ t.maximumNumberOfLines = maxLines
|
|
|
580
|
+ }
|
|
|
581
|
+ t.font = font
|
|
|
582
|
+ t.textColor = color
|
|
|
583
|
+ t.alignment = .left
|
|
|
584
|
+ return t
|
|
|
585
|
+ }
|
|
|
586
|
+
|
|
|
587
|
+ private func displayable(_ value: String, placeholder: String) -> String {
|
|
|
588
|
+ let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
589
|
+ return t.isEmpty ? placeholder : t
|
|
|
590
|
+ }
|
|
|
591
|
+
|
|
|
592
|
+ private func nonEmpty(_ value: String) -> String? {
|
|
|
593
|
+ let t = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
594
|
+ return t.isEmpty ? nil : t
|
|
|
595
|
+ }
|
|
|
596
|
+
|
|
|
597
|
+ private static func bulletChunks(from text: String) -> [String] {
|
|
|
598
|
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
599
|
+ if trimmed.isEmpty { return [] }
|
|
|
600
|
+ let byNewline = trimmed.components(separatedBy: .newlines)
|
|
|
601
|
+ .map { $0.trimmingCharacters(in: .whitespaces) }
|
|
|
602
|
+ .filter { !$0.isEmpty }
|
|
|
603
|
+ if byNewline.count > 1 { return byNewline }
|
|
|
604
|
+ let byBullet = trimmed.split(separator: "•")
|
|
|
605
|
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
|
606
|
+ .filter { !$0.isEmpty }
|
|
|
607
|
+ if byBullet.count > 1 { return byBullet.map { String($0) } }
|
|
|
608
|
+ return [trimmed]
|
|
|
609
|
+ }
|
|
|
610
|
+}
|
|
|
611
|
+
|
|
|
612
|
+// MARK: - Payload helpers
|
|
|
613
|
+
|
|
|
614
|
+private extension WorkExperiencePayload {
|
|
|
615
|
+ var isEffectivelyEmpty: Bool {
|
|
|
616
|
+ [jobTitle, company, duration, description].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
|
617
|
+ }
|
|
|
618
|
+}
|
|
|
619
|
+
|
|
|
620
|
+private extension EducationPayload {
|
|
|
621
|
+ var isEffectivelyEmpty: Bool {
|
|
|
622
|
+ [degree, institution, year].allSatisfy { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
|
623
|
+ }
|
|
|
624
|
+}
|